mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
97d1052dd1
commit
eae2138f3e
2 files changed
+38
-34
No files matched your search
@@ -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<Record<string, unknown>>;
|
||||
};
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
const parsed = (
|
||||
vi.mocked(auditLog).mock.calls[0] as unknown as [
|
||||
string,
|
||||
{ details: { issues: Array<Record<string, unknown>> } },
|
||||
]
|
||||
)[1].details;
|
||||
for (const issue of parsed.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user