5f01df819e
The PATCH handler returned the full businessSettings row via .returning(), echoing the encrypted googleMapsApiKey ciphertext back to the caller. Wrap the return in the existing redactSettings() helper (after a !updated guard) so redaction is applied symmetrically with the GET projection (GRO-2294). - src/routes/settings.ts: guard + redactSettings(updated) on PATCH return - src/__tests__/settings.test.ts: assert PATCH omits googleMapsApiKey (existing-row and auto-create-then-update branches) - UAT_PLAYBOOK.md §13 TC-API-13.2: assert PATCH response omits the secret Co-Authored-By: Paperclip <noreply@paperclip.ing>
146 lines
4.9 KiB
TypeScript
146 lines
4.9 KiB
TypeScript
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>[] = [];
|
|
let updateReturning: 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 }),
|
|
}),
|
|
update: () => ({
|
|
set: () => ({ where: () => ({ returning: () => updateReturning }) }),
|
|
}),
|
|
}),
|
|
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);
|
|
|
|
// 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",
|
|
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");
|
|
});
|
|
});
|
|
|
|
describe("PATCH /settings — googleMapsApiKey redaction (GRO-2299)", () => {
|
|
beforeEach(() => {
|
|
selectRows = [];
|
|
insertReturning = [];
|
|
updateReturning = [];
|
|
});
|
|
|
|
function patchRequest(body: Record<string, unknown>) {
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
expect(body).not.toHaveProperty("googleMapsApiKey");
|
|
expect(body.id).toBe("settings-uuid-new");
|
|
});
|
|
});
|