From b4b48f7b50d8ef9f5129024b2b65bb51d2ead802 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 06:52:48 +0000 Subject: [PATCH] fix(GRO-2299): redact googleMapsApiKey from PATCH /api/admin/settings response (#195) --- UAT_PLAYBOOK.md | 2 +- src/__tests__/settings.test.ts | 54 ++++++++++++++++++++++++++++++++++ src/routes/settings.ts | 3 +- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 48082de..ecccc77 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -333,7 +333,7 @@ This means: | # | Scenario | Steps | Expected | |---|----------|-------|----------| | 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.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the PATCH response symmetrically with the GET projection (GRO-2299, defense-in-depth); non-secret updated fields are still returned | | 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 | | TC-API-13.5 | Delete logo | DELETE /api/admin/settings/logo | 200 OK, logo removed | diff --git a/src/__tests__/settings.test.ts b/src/__tests__/settings.test.ts index c878999..5cdccca 100644 --- a/src/__tests__/settings.test.ts +++ b/src/__tests__/settings.test.ts @@ -7,6 +7,7 @@ import { Hono } from "hono"; let selectRows: Record[] = []; let insertReturning: Record[] = []; +let updateReturning: Record[] = []; function makeChainable(data: unknown[]): unknown { const arr = [...data]; @@ -33,6 +34,9 @@ vi.mock("@groombook/db", () => { insert: () => ({ values: () => ({ returning: () => insertReturning }), }), + update: () => ({ + set: () => ({ where: () => ({ returning: () => updateReturning }) }), + }), }), businessSettings, eq: vi.fn(), @@ -51,6 +55,17 @@ const { settingsRouter } = await import("../routes/settings.js"); const app = new Hono(); app.route("/settings", settingsRouter); +// PATCH /settings is guarded by requireSuperUser(), which reads the staff record +// from context. Inject a super-user staff row so the handler runs. +const patchApp = new Hono<{ + Variables: { staff: { id: string; isSuperUser: boolean } }; +}>(); +patchApp.use("*", async (c, next) => { + c.set("staff", { id: "staff-1", isSuperUser: true }); + await next(); +}); +patchApp.route("/settings", settingsRouter); + const FULL_ROW = { id: "settings-uuid-1", businessName: "GroomBook", @@ -89,3 +104,42 @@ describe("GET /settings — googleMapsApiKey redaction (GRO-2294)", () => { expect(body.id).toBe("settings-uuid-new"); }); }); + +describe("PATCH /settings — googleMapsApiKey redaction (GRO-2299)", () => { + beforeEach(() => { + selectRows = []; + insertReturning = []; + updateReturning = []; + }); + + function patchRequest(body: Record) { + return patchApp.request("/settings", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("omits googleMapsApiKey from the PATCH response", async () => { + selectRows = [{ ...FULL_ROW }]; + updateReturning = [{ ...FULL_ROW, businessName: "Updated Name" }]; + const res = await patchRequest({ businessName: "Updated Name" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + // Non-secret updated fields are still returned. + expect(body.businessName).toBe("Updated Name"); + expect(body.routeOptimizationProvider).toBe("google"); + }); + + it("omits googleMapsApiKey on the auto-create-then-update branch", async () => { + selectRows = []; + insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + updateReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + const res = await patchRequest({ primaryColor: "#123456" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + expect(body.id).toBe("settings-uuid-new"); + }); +}); diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 8529135..bcb4476 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -65,7 +65,8 @@ settingsRouter.patch( .where(eq(businessSettings.id, settingsId)) .returning(); - return c.json(updated); + if (!updated) throw new Error("Failed to update settings"); + return c.json(redactSettings(updated)); } );