From eae2138f3ea1954973199ab0446d299c882199c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 07:29:53 +0200 Subject: [PATCH] fix(audit): route the widget-layout refusal through auditLog The dashboard layout route wrote its validation breadcrumb with a bare auditLog.create, so it skipped the delegated-only rule and kept filing the caller's own refusals. --- .../dashboard/widgets/__tests__/route.test.ts | 52 +++++++++++-------- src/app/api/dashboard/widgets/route.ts | 20 ++++--- 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index fc9bc3e33..dda5ae3e1 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -4,9 +4,10 @@ * Until v1.4.41 this route returned `parsed.error.issues[0].message`, * which dropped every issue past the first. iOS contract debugging * needed one round-trip per wrong field. The route now returns every - * issue under `details.issues` AND writes a - * `dashboard.widgets.validation-failed` audit-ledger row so the - * operator can grep `/api/admin/audit` for the same trail. + * issue under `details.issues` AND files a + * `dashboard.widgets.validation-failed` breadcrumb through `auditLog()`, + * which since v1.43.7 writes it only for a delegate's request (the + * decision is tested in `src/lib/auth/__tests__/audit.test.ts`). */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -17,6 +18,10 @@ const transactionUser = vi.hoisted(() => ({ update: vi.fn(), })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn(async () => undefined), +})); + vi.mock("@/lib/db", () => ({ prisma: { user: { @@ -91,6 +96,7 @@ import { type DashboardLayoutWithToken, } from "@/lib/dashboard-layout"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +import { auditLog } from "@/lib/auth/audit"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -198,15 +204,17 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", // The audit row is fire-and-forget — let the microtask queue drain. await new Promise((r) => setTimeout(r, 5)); - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { - data: { userId: string; action: string; details: string }; - }; - expect(call.data.userId).toBe("user-1"); - expect(call.data.action).toBe("dashboard.widgets.validation-failed"); - const details = JSON.parse(call.data.details) as { - issues: Array<{ path: string; code: string }>; - }; + expect(auditLog).toHaveBeenCalledTimes(1); + const [action, opts] = vi.mocked(auditLog).mock.calls[0] as unknown as [ + string, + { + userId: string; + details: { issues: Array<{ path: string; code: string }> }; + }, + ]; + expect(opts.userId).toBe("user-1"); + expect(action).toBe("dashboard.widgets.validation-failed"); + const details = opts.details; expect(details.issues.length).toBe(2); for (const issue of details.issues) { // v1.4.49 — audit row is the persisted surface. `message` @@ -226,7 +234,7 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", await new Promise((r) => setTimeout(r, 5)); - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + expect(auditLog).toHaveBeenCalledTimes(1); // The full multi-issue envelope still rides on every 422 — the // dedup only suppresses the breadcrumb write, never the response. @@ -297,9 +305,7 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", }); it("does not block the 422 response when the audit-row write rejects", async () => { - vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( - new Error("db down"), - ); + vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down")); const res = await callPut(makeReq({ version: 2, widgets: [] })); // The response is the contract — the audit row is best-effort. @@ -361,13 +367,13 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", // leak into the ledger. await callPut(makeReq({ version: 2, widgets: [] })); - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { - data: { details: string }; - }; - const parsed = JSON.parse(call.data.details) as { - issues: Array>; - }; + expect(auditLog).toHaveBeenCalledTimes(1); + const parsed = ( + vi.mocked(auditLog).mock.calls[0] as unknown as [ + string, + { details: { issues: Array> } }, + ] + )[1].details; for (const issue of parsed.issues) { expect(Object.keys(issue).sort()).toEqual(["code", "path"]); } diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index d4d3675b1..b10800887 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -68,6 +68,7 @@ const widgetIdEnum = z.enum(DASHBOARD_WIDGET_CATALOGUE_IDS); // the same 60 s `(userId, action)` window. export { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup"; import { shouldEmitAuditRow } from "@/lib/audit-dedup"; +import { auditLog } from "@/lib/auth/audit"; const layoutSchema = z.object({ version: z.literal(1), @@ -379,17 +380,14 @@ export const PUT = apiHandler(async (request: NextRequest) => { const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "dashboard.widgets.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — validation response is the contract, audit row is best-effort */ - }); + // Through `auditLog()`, which files a refused attempt only when a + // delegate made it; the caller's own refusal stays on the wide event. + void auditLog("dashboard.widgets.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — validation response is the contract, audit row is best-effort */ + }); } return returnAllZodIssues(parsed.error, 422); }