mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(api/dashboard): return every Zod issue on widgets PUT 422
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.
This commit is contained in:
1 parent
098ca4aed0
commit
876a545d9c
3 files changed
+281
-2
No files matched your search
@@ -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<Response>;
|
||||
|
||||
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<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Record<string, unknown>> };
|
||||
};
|
||||
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");
|
||||
|
||||
Reference in new issue
Block a user