Compare commits

...

1 Commits

Author SHA1 Message Date
Flea Flicker c7007051d7 GRO-2294: Route Optimization security hardening (LOW)
CI / Test (pull_request) Successful in 28s
CI / Lint & Typecheck (pull_request) Successful in 34s
CI / Build & Push Docker Images (pull_request) Successful in 1m28s
Two defense-in-depth fixes from the GRO-2162 feature-level security review:

1. Enforce the documented ?limit cap on POST /api/clients/geocode-batch.
   The handler now clamps limit to GEOCODE_BATCH_MAX_LIMIT (500) after the
   positive-integer check, bounding synchronous request duration and per-request
   external API cost when routeOptimizationProvider = "google".

2. Redact the encrypted googleMapsApiKey from GET /api/admin/settings on both
   the existing-row and auto-create branches. The ciphertext is never needed
   client-side and is now stripped via redactSettings().

Adds route-level tests for the limit clamp (default/passthrough/clamp/floor/
reject) and the settings redaction (both branches). Updates UAT_PLAYBOOK.md
TC-API-2.13a and TC-API-13.1.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-09 06:12:42 +00:00
5 changed files with 206 additions and 4 deletions
+2 -1
View File
@@ -133,6 +133,7 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
| TC-API-2.11 | Geocode endpoint is manager-only | As **groomer** or **receptionist**, `POST /api/clients/{id}/geocode` | 403 Forbidden (role not permitted) |
| TC-API-2.12 | Batch geocode un-geocoded clients | As manager, `POST /api/clients/geocode-batch?limit=10` on a DB with un-geocoded clients | 200 OK; body `{ provider, processed, geocoded, unresolved, errors, remaining, outcomes[] }`. `processed` ≤ 10; `remaining` reflects un-geocoded clients beyond this batch. Re-run while `remaining > 0` to finish (throttled to provider rate limit) |
| TC-API-2.13 | Batch geocode — invalid limit | As manager, `POST /api/clients/geocode-batch?limit=0` (or non-numeric) | 400 `{ error: "limit must be a positive integer" }` |
| TC-API-2.13a | Batch geocode — `?limit` cap enforced (GRO-2294) | As manager, `POST /api/clients/geocode-batch?limit=100000` on a DB with un-geocoded clients | 200 OK; the request is **clamped to the documented max of 500**`processed` ≤ 500 (never the raw 100000). A fractional `?limit` (e.g. `49.9`) is floored to `49`. Confirms a manager cannot hold one synchronous request open / accrue unbounded Google API cost via an oversized limit |
| TC-API-2.14 | Batch geocode — manager-only | As groomer/receptionist, `POST /api/clients/geocode-batch` | 403 Forbidden |
| TC-API-2.15 | Auto-geocode on create | As manager/receptionist, `POST /api/clients` with a valid `address` | 201 Created; response includes a `geocoding` object (`status: "geocoded"` for a resolvable address) and the persisted client carries `latitude`/`longitude`/`geocodedAt`. Creating without an address succeeds with no `geocoding` field |
| TC-API-2.16 | Auto-geocode on address update | As manager/receptionist, `PATCH /api/clients/{id}` changing `address` to a new valid value | 200 OK; response includes a `geocoding` object and refreshed coordinates. Patching unrelated fields (e.g. `name`) does NOT re-geocode (no `geocoding` field) |
@@ -331,7 +332,7 @@ This means:
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned |
| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present |
| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated |
| TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored |
| TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned |
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mocks ──────────────────────────────────────────────────────────────────
// GRO-2294: the POST /clients/geocode-batch handler must clamp ?limit to the
// documented maximum (500) before invoking the geocoding service. We mock the
// service to capture the exact limit the route forwards.
const geocodeUngeocodedClients = vi.fn(async () => ({
totalRemaining: 0,
processed: 0,
geocoded: 0,
failed: 0,
remaining: 0,
}));
vi.mock("../services/clientGeocoding.js", () => ({
geocodeUngeocodedClients,
geocodeClient: vi.fn(),
resolveClientGeocodingProvider: vi.fn(),
}));
vi.mock("@groombook/db", () => {
const tableProxy = (name: string) =>
new Proxy(
{ _name: name },
{ get: (_t, p) => (p === "_name" ? name : { table: name, column: p }) }
);
return {
getDb: () => ({}),
clients: tableProxy("clients"),
appointments: tableProxy("appointments"),
and: vi.fn(),
eq: vi.fn(),
or: vi.fn(),
exists: vi.fn(),
};
});
const { clientsRouter } = await import("../routes/clients.js");
const app = new Hono();
app.route("/clients", clientsRouter);
function postBatch(query: string) {
return app.request(`/clients/geocode-batch${query}`, { method: "POST" });
}
describe("POST /clients/geocode-batch — ?limit cap (GRO-2294)", () => {
beforeEach(() => {
geocodeUngeocodedClients.mockClear();
});
it("defaults to 50 when no ?limit is supplied", async () => {
const res = await postBatch("");
expect(res.status).toBe(200);
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 50);
});
it("passes through a value within the cap", async () => {
const res = await postBatch("?limit=120");
expect(res.status).toBe(200);
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 120);
});
it("clamps an over-cap value to 500", async () => {
const res = await postBatch("?limit=100000");
expect(res.status).toBe(200);
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 500);
});
it("floors a fractional value before clamping", async () => {
const res = await postBatch("?limit=49.9");
expect(res.status).toBe(200);
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 49);
});
it("rejects a non-positive limit with 400", async () => {
const res = await postBatch("?limit=0");
expect(res.status).toBe(400);
expect(geocodeUngeocodedClients).not.toHaveBeenCalled();
});
it("rejects a non-numeric limit with 400", async () => {
const res = await postBatch("?limit=abc");
expect(res.status).toBe(400);
expect(geocodeUngeocodedClients).not.toHaveBeenCalled();
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mocks ──────────────────────────────────────────────────────────────────
// GRO-2294: GET /api/admin/settings must not return the encrypted
// googleMapsApiKey ciphertext, on either the existing-row or auto-create branch.
let selectRows: Record<string, unknown>[] = [];
let insertReturning: Record<string, unknown>[] = [];
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit") {
return () => chain;
}
// @ts-expect-error proxy passthrough
return target[prop];
},
});
return chain;
}
vi.mock("@groombook/db", () => {
const businessSettings = new Proxy(
{ _name: "business_settings" },
{ get: (_t, p) => (p === "_name" ? "business_settings" : { column: p }) }
);
return {
getDb: () => ({
select: () => ({ from: () => makeChainable(selectRows) }),
insert: () => ({
values: () => ({ returning: () => insertReturning }),
}),
}),
businessSettings,
eq: vi.fn(),
};
});
vi.mock("../lib/s3.js", () => ({
getPresignedUploadUrl: vi.fn(),
deleteObject: vi.fn(),
putObject: vi.fn(),
getObject: vi.fn(),
}));
const { settingsRouter } = await import("../routes/settings.js");
const app = new Hono();
app.route("/settings", settingsRouter);
const FULL_ROW = {
id: "settings-uuid-1",
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
routeOptimizationProvider: "google",
googleMapsApiKey: "ENCRYPTED::super-secret-ciphertext",
createdAt: new Date(),
updatedAt: new Date(),
};
describe("GET /settings — googleMapsApiKey redaction (GRO-2294)", () => {
beforeEach(() => {
selectRows = [];
insertReturning = [];
});
it("omits googleMapsApiKey from an existing settings row", async () => {
selectRows = [{ ...FULL_ROW }];
const res = await app.request("/settings", { method: "GET" });
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body).not.toHaveProperty("googleMapsApiKey");
// Non-secret fields are still returned.
expect(body.businessName).toBe("GroomBook");
expect(body.routeOptimizationProvider).toBe("google");
});
it("omits googleMapsApiKey from the auto-create branch", async () => {
selectRows = [];
insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }];
const res = await app.request("/settings", { method: "GET" });
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body).not.toHaveProperty("googleMapsApiKey");
expect(body.id).toBe("settings-uuid-new");
});
});
+10 -1
View File
@@ -12,6 +12,12 @@ import {
export const clientsRouter = new Hono<AppEnv>();
// Batch-geocode bounds (GRO-2294): default 50, hard cap 500. The cap bounds how
// long one synchronous request stays open and the per-request external API cost
// when routeOptimizationProvider = "google".
const GEOCODE_BATCH_DEFAULT_LIMIT = 50;
const GEOCODE_BATCH_MAX_LIMIT = 500;
type ClientRow = typeof clients.$inferSelect;
/**
@@ -185,12 +191,15 @@ clientsRouter.post("/:clientId/geocode", async (c) => {
clientsRouter.post("/geocode-batch", async (c) => {
const db = getDb();
const limitRaw = c.req.query("limit");
let limit = 50;
let limit = GEOCODE_BATCH_DEFAULT_LIMIT;
if (limitRaw !== undefined) {
limit = Number(limitRaw);
if (!Number.isFinite(limit) || limit <= 0) {
return c.json({ error: "limit must be a positive integer" }, 400);
}
// Clamp to the documented maximum to bound synchronous request duration
// and (for the Google provider) per-request external API cost.
limit = Math.min(Math.floor(limit), GEOCODE_BATCH_MAX_LIMIT);
}
const summary = await geocodeUngeocodedClients(db, limit);
return c.json(summary);
+14 -2
View File
@@ -7,6 +7,17 @@ import { requireSuperUser } from "../middleware/rbac.js";
export const settingsRouter = new Hono();
type BusinessSettingsRow = typeof businessSettings.$inferSelect;
// Strip the encrypted googleMapsApiKey ciphertext from settings responses
// (GRO-2294, defense-in-depth). The secret is never needed client-side; it is
// only written via the dedicated provider-config endpoint.
function redactSettings(row: BusinessSettingsRow) {
const rest: Partial<BusinessSettingsRow> = { ...row };
delete rest.googleMapsApiKey;
return rest;
}
// GET /api/admin/settings — return current business settings
settingsRouter.get("/", async (c) => {
const db = getDb();
@@ -14,9 +25,10 @@ settingsRouter.get("/", async (c) => {
if (!row) {
// Auto-create default settings if none exist
const [created] = await db.insert(businessSettings).values({}).returning();
return c.json(created);
if (!created) throw new Error("Failed to create default settings");
return c.json(redactSettings(created));
}
return c.json(row);
return c.json(redactSettings(row));
});
const hexColorRegex = /^#[0-9a-fA-F]{6}$/;