From 876a545d9cafb5b8730ab2889673836f3f226cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 18:51:05 +0200 Subject: [PATCH] feat(api/dashboard): return every Zod issue on widgets PUT 422 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PUT /api/dashboard/widgets route surfaced only the first Zod issue on 422, which forced iOS clients debugging the widget-layout contract into one round-trip per wrong field. The route now wires the shared returnAllZodIssues helper so every issue lands in details.issues at once. Adds an append-only audit-ledger breadcrumb keyed dashboard.widgets.validation-failed so the operator can grep /api/admin/audit for the same trail outside the iOS dev console. The write is fire-and-forget — the validation response is the contract, the audit row is best-effort. Tests cover 2 + 3 simultaneous issues, audit-row shape, params-leak guard, and audit-write-rejection survival on both the unit and integration tiers. --- .../dashboard/widgets/__tests__/route.test.ts | 160 ++++++++++++++++++ src/app/api/dashboard/widgets/route.ts | 33 +++- .../dashboard-widgets-save.test.ts | 90 ++++++++++ 3 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 src/app/api/dashboard/widgets/__tests__/route.test.ts diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts new file mode 100644 index 000000000..4ebb3840c --- /dev/null +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -0,0 +1,160 @@ +/** + * v1.4.42 W2 — multi-issue 422 envelope on PUT. + * + * 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. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + }, + toJson: (v: unknown) => v, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: vi.fn(), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + displayName: null, + }, +}; + +const callPut = PUT as unknown as (req: NextRequest) => Promise; + +function makeReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/dashboard/widgets", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + __resetAllCachesForTests(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); +}); + +describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", () => { + it("surfaces TWO simultaneous validation errors under details.issues", async () => { + // version=2 (literal mismatch) + widgets=[] (min(1) violation). + const res = await callPut(makeReq({ version: 2, widgets: [] })); + expect(res.status).toBe(422); + + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBe(2); + const paths = body.details.issues.map((i) => i.path).sort(); + expect(paths).toEqual(["version", "widgets"]); + + // Every issue carries exactly path / code / message — issue.params + // never leaks (it may echo the offending user input for some codes). + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + const res = await callPut( + makeReq({ + version: 99, + widgets: [], + comparisonBaseline: "tomorrow", + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ path: string; code: string }> }; + }; + expect(body.details.issues.length).toBe(3); + const paths = body.details.issues.map((i) => i.path).sort(); + expect(paths).toEqual(["comparisonBaseline", "version", "widgets"]); + }); + + it("writes one audit-ledger row keyed dashboard.widgets.validation-failed", async () => { + const res = await callPut(makeReq({ version: 2, widgets: [] })); + expect(res.status).toBe(422); + + // 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; message: string }>; + }; + expect(details.issues.length).toBe(2); + for (const issue of details.issues) { + // Audit row carries the same sanitised shape — issue.params never + // hits the ledger either. + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("does not block the 422 response when the audit-row write rejects", async () => { + vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( + new Error("db down"), + ); + + const res = await callPut(makeReq({ version: 2, widgets: [] })); + // The response is the contract — the audit row is best-effort. + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBe(2); + }); +}); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 28588e5f9..dec1978be 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -6,7 +6,12 @@ * to default. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + safeJson, + returnAllZodIssues, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma, toJson } from "@/lib/db"; import { @@ -126,7 +131,31 @@ export const PUT = apiHandler(async (request: NextRequest) => { const parsed = layoutSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.42 W2 — the legacy `issues[0].message` envelope dropped + // every issue past the first. iOS contract debugging hit one + // round-trip per wrong field; the new helper surfaces every + // issue under `details.issues`. We additionally emit an + // append-only audit-ledger row so the operator can grep + // `/api/admin/audit` for these without combing the iOS dev + // console. + const issues = sanitiseZodIssues(parsed.error.issues); + annotate({ + action: { name: "dashboard.widgets.validation-failed" }, + meta: { issue_count: issues.length }, + }); + // Best-effort breadcrumb — never block the 422 on a write miss. + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "dashboard.widgets.validation-failed", + details: JSON.stringify({ issues }), + }, + }) + .catch(() => { + /* swallow — validation response is the contract, audit row is best-effort */ + }); + return returnAllZodIssues(parsed.error, 422); } // v1.4.18 — preserve any per-chart overlay prefs that the client diff --git a/tests/integration/dashboard-widgets-save.test.ts b/tests/integration/dashboard-widgets-save.test.ts index e25d45aa2..4dfb212e9 100644 --- a/tests/integration/dashboard-widgets-save.test.ts +++ b/tests/integration/dashboard-widgets-save.test.ts @@ -237,6 +237,96 @@ describe("PUT /api/dashboard/widgets — Save persists full layouts", () => { expect(res.status).toBe(200); }); + // v1.4.42 W2 — the route used to return only `issues[0].message`, + // which forced one round-trip per wrong field during iOS contract + // debugging. The route now returns every issue under `details.issues` + // AND writes a `dashboard.widgets.validation-failed` row to the + // append-only audit ledger so the operator can grep `/api/admin/audit`. + describe("PUT 422 — multi-issue envelope (v1.4.42 W2)", () => { + it("surfaces TWO simultaneous validation errors", async () => { + const { userId } = await seedUser(); + const { PUT } = await import("@/app/api/dashboard/widgets/route"); + + // version=2 (literal mismatch) + widgets=[] (min(1) violation). + const res = await (PUT as (req: Request) => Promise)( + new Request("http://localhost/api/dashboard/widgets", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ version: 2, widgets: [] }), + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { issues: Array<{ path: string; code: string; message: string }> }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBe(2); + const paths = body.details.issues.map((i) => i.path).sort(); + expect(paths).toEqual(["version", "widgets"]); + + // Audit-ledger breadcrumb. The route emits the row fire-and-forget, + // so we may need a tick before the row materialises. + await new Promise((r) => setTimeout(r, 25)); + const audit = await getPrismaClient().auditLog.findFirst({ + where: { userId, action: "dashboard.widgets.validation-failed" }, + }); + expect(audit).not.toBeNull(); + const details = JSON.parse(audit?.details ?? "{}") as { + issues: Array<{ path: string }>; + }; + expect(details.issues.length).toBe(2); + }); + + it("surfaces THREE simultaneous validation errors", async () => { + await seedUser(); + const { PUT } = await import("@/app/api/dashboard/widgets/route"); + + // version wrong literal + widgets empty + comparisonBaseline not in enum. + const res = await (PUT as (req: Request) => Promise)( + new Request("http://localhost/api/dashboard/widgets", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + version: 99, + widgets: [], + comparisonBaseline: "tomorrow", + }), + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ path: string; code: string }> }; + }; + expect(body.details.issues.length).toBe(3); + const paths = body.details.issues.map((i) => i.path).sort(); + expect(paths).toEqual(["comparisonBaseline", "version", "widgets"]); + }); + + it("does not echo issue.params (privacy)", async () => { + await seedUser(); + const { PUT } = await import("@/app/api/dashboard/widgets/route"); + + const res = await (PUT as (req: Request) => Promise)( + new Request("http://localhost/api/dashboard/widgets", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ version: 1, widgets: [] }), + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array> }; + }; + for (const issue of body.details.issues) { + expect(issue).not.toHaveProperty("params"); + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + }); + it("widget toggle changes round-trip end-to-end", async () => { const { userId } = await seedUser(); const { PUT, GET } = await import("@/app/api/dashboard/widgets/route");