fix(audit): file every refused request through auditLog()

Twelve routes wrote their <surface>.validation-failed row with a bare
prisma.auditLog.create, so they skipped the two decisions auditLog makes:
an owner's own refused request stays on the wide event
(meta.rejected_attempt) instead of becoming a row, and a delegate's names
the delegate as the actor. The bulk medication and mood ingests, the
layout, overlay, device, health-sync, metric and ingest routes all go
through auditLog now, and a structural guard keeps raw writes to the one
writer and the briefing failure marker. The unit tests assert the auditLog
call, and the bulk walks are re-pinned without the owner's refusal row.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 16:54:57 +02:00
1 parent 89b6189206
commit 55a6287069
30 files changed
+217 -319

No files matched your search

@@ -0,0 +1,61 @@
/**
* Structural guard: audit rows are written through `auditLog()`.
*
* `auditLog` decides two things a bare `prisma.auditLog.create` cannot: who
* acted when the row is not theirs (the delegate on a shared record), and
* whether a refused attempt (`<surface>.validation-failed`) becomes a row
* at all. An owner's own refused request stays on the wide event; only a
* refusal somebody else caused reaches the ledger the owner's activity feed
* reads. Twelve routes wrote their refusal row directly and filed every
* malformed body an owner's app sent, a few per sync, with no actor on a
* delegate's.
*
* Whitespace-tolerant: the calls were written across two lines
* (`prisma.auditLog` then `.create(`).
*/
import { describe, expect, it } from "vitest";
import { readSourceFolder, stripComments } from "./helpers/source-files";
/** Files that write the table directly, and why. */
const DIRECT_WRITERS: Record<string, string> = {
"lib/auth/audit.ts": "the one writer",
"lib/insights/briefing-failure-marker.ts":
"a dated failure marker the briefing read looks up again, not an audit of an act",
};
const RAW_WRITE = /\bauditLog\s*\.\s*create(?:Many(?:AndReturn)?)?\s*\(/;
describe("audit rows go through auditLog()", () => {
const files = readSourceFolder("src", {
floor: 3000,
include: (rel) =>
!rel.startsWith("generated/") &&
!rel.includes("__tests__/") &&
!/\.test\.tsx?$/.test(rel),
});
const writers = files
.filter(([, text]) => RAW_WRITE.test(stripComments(text)))
.map(([rel]) => rel);
it("finds the writers it polices", () => {
expect(writers).toContain("lib/auth/audit.ts");
});
it("no other file writes the audit table directly", () => {
expect(writers.filter((rel) => !(rel in DIRECT_WRITERS))).toEqual([]);
});
it("lists no file that stopped writing it", () => {
expect(
Object.keys(DIRECT_WRITERS).filter((rel) => !writers.includes(rel)),
).toEqual([]);
});
it("the matcher reads a call split across lines", () => {
expect(RAW_WRITE.test("prisma.auditLog\n .create({ data })")).toBe(
true,
);
expect(RAW_WRITE.test('auditLog("x.validation-failed", {})')).toBe(false);
});
});
@@ -57,17 +57,12 @@ export const PATCH = apiHandler(
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.entry.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, entryId }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
auditLog("custom-metric.entry.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, entryId },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
+9 -14
View File
@@ -89,20 +89,15 @@ export const PATCH = apiHandler(
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.metric.update.validation-failed",
details: JSON.stringify({
issues: auditIssues,
customMetricId: id,
}),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
auditLog("custom-metric.metric.update.validation-failed", {
userId: user.id,
details: {
issues: auditIssues,
customMetricId: id,
},
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
@@ -42,6 +42,7 @@ vi.mock("next/headers", () => ({
import { GET, POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -73,6 +74,7 @@ const ROW = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
+6 -11
View File
@@ -70,17 +70,12 @@ async function postCustomMetric(request: NextRequest) {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.metric.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
auditLog("custom-metric.metric.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
@@ -109,7 +109,7 @@ describe("PUT /api/dashboard/chart-overlay-prefs — 422 multi-issue (v1.4.43 W6
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a dashboard.chart-overlay.validation-failed audit row", async () => {
it("keeps the owner's own dashboard.chart-overlay refusal off the ledger", async () => {
const res = await PUT(
putReq({
chartKey: "not-a-chart",
@@ -122,11 +122,9 @@ describe("PUT /api/dashboard/chart-overlay-prefs — 422 multi-issue (v1.4.43 W6
);
expect(res.status).toBe(422);
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 };
};
expect(call.data.action).toBe("dashboard.chart-overlay.validation-failed");
// An owner's own refused request is not an audit event: `auditLog`
// keeps it on the wide event and files no row.
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it("does not block the 422 when the audit-row write rejects", async () => {
@@ -15,6 +15,7 @@
* the new prefs for the supplied `chartKey`, and writes it back inside the
* same request.
*/
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, sanitiseZodIssues } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
@@ -72,17 +73,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
shouldEmitAuditRow(user.id, "dashboard.chart-overlay.validation-failed")
) {
const issues = sanitiseZodIssues(error.issues);
prisma.auditLog
.create({
data: {
userId: user.id,
action: "dashboard.chart-overlay.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
auditLog("dashboard.chart-overlay.validation-failed", {
userId: user.id,
details: { issues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
}
},
});
+8 -9
View File
@@ -62,6 +62,7 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -78,6 +79,7 @@ function req(body: unknown): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(prisma.device.findUnique).mockResolvedValue(null);
vi.mocked(prisma.device.findFirst).mockResolvedValue(null);
vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never);
@@ -406,19 +408,16 @@ describe("POST /api/devices — 422 multi-issue envelope (v1.4.43 W6)", () => {
);
expect(res.status).toBe(422);
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("devices.register.validation-failed");
// Through `auditLog()`, which decides whether the refusal is filed.
expect(auditLog).toHaveBeenCalledWith(
"devices.register.validation-failed",
expect.objectContaining({ userId: "user-1" }),
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await POST(
req({ bundleId: "io.healthlog.app", apnsEnvironment: "tomorrow" }),
);
+6 -11
View File
@@ -124,17 +124,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "devices.register.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
auditLog("devices.register.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
@@ -66,6 +66,7 @@ import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit, checkRateLimit } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { auditLog } from "@/lib/auth/audit";
function postReq(
body: unknown,
@@ -83,6 +84,7 @@ function postReq(
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
@@ -145,18 +147,15 @@ describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => {
const res = await POST(postReq({ medicationName: "" }));
expect(res.status).toBe(422);
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 };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("ingest.medication.validation-failed");
// Through `auditLog()`, which decides whether the refusal is filed.
expect(auditLog).toHaveBeenCalledWith(
"ingest.medication.validation-failed",
expect.objectContaining({ userId: "user-1" }),
);
});
it("does not block the 422 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 POST(postReq({ medicationName: "" }));
expect(res.status).toBe(422);
});
+6 -11
View File
@@ -174,17 +174,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: apiToken.userId,
action: "ingest.medication.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
auditLog("ingest.medication.validation-failed", {
userId: apiToken.userId,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
@@ -44,6 +44,12 @@ vi.mock("@/lib/logging/context", async (importOriginal) => {
};
});
// The real `auditLog`, observed: it decides whether a refusal is filed.
vi.mock("@/lib/auth/audit", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/auth/audit")>();
return { ...actual, auditLog: vi.fn(actual.auditLog) };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -57,6 +63,7 @@ import { GET, PUT, DELETE } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
import { DEFAULT_INSIGHTS_LAYOUT } from "@/lib/insights-layout";
@@ -442,31 +449,31 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
);
});
it("writes one audit-ledger row keyed insights.layout.validation-failed", async () => {
it("hands the insights.layout.validation-failed refusal to auditLog, which files no row for the owner", async () => {
const res = await callPut(makeReq({ version: 99, tiles: [] }));
expect(res.status).toBe(422);
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("insights.layout.validation-failed");
const details = JSON.parse(call.data.details) as {
expect(auditLog).toHaveBeenCalledTimes(1);
const [action, opts] = vi.mocked(auditLog).mock.calls[0]!;
expect(action).toBe("insights.layout.validation-failed");
expect(opts?.userId).toBe("user-1");
// An owner's own refused request is not an audit event.
expect(prisma.auditLog.create).not.toHaveBeenCalled();
const details = opts?.details as {
issues: Array<{ path: string; code: string }>;
};
expect(details.issues.length).toBe(2);
for (const issue of details.issues) {
// The persisted row strips `message` so a future Zod code that
// The filed issues strip `message` so a future Zod code that
// echoes the offending value cannot leak into the audit
// surface.
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
it("dedups the audit-ledger write across two sequential 422s for the same user", async () => {
it("dedups the audit call across two sequential 422s for the same user", async () => {
const res1 = await callPut(makeReq({ version: 99, tiles: [] }));
expect(res1.status).toBe(422);
const res2 = await callPut(makeReq({ version: 99, tiles: [] }));
@@ -474,7 +481,8 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
expect(auditLog).toHaveBeenCalledTimes(1);
expect(prisma.auditLog.create).not.toHaveBeenCalled();
// The full multi-issue envelope still rides on every 422 — the
// dedup only suppresses the breadcrumb write, never the response.
+7 -11
View File
@@ -13,6 +13,7 @@
* single blob with a `surface` discriminator would force every read to
* demux first.
*/
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
@@ -178,17 +179,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "insights.layout.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — validation response is the contract, audit row is best-effort */
});
auditLog("insights.layout.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);
}
@@ -48,6 +48,7 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
import { APPLE_HEALTH_DATA_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -56,6 +57,7 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: null,
@@ -220,18 +222,16 @@ describe("PATCH /api/integrations/healthkit — 422 multi-issue (v1.4.43 W6)", (
);
expect(res.status).toBe(422);
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 };
};
expect(call.data.action).toBe("integrations.healthkit.validation-failed");
// Through `auditLog()`, which decides whether the refusal is filed.
expect(auditLog).toHaveBeenCalledWith(
"integrations.healthkit.validation-failed",
expect.anything(),
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await PATCH(
patchReq({ entries: [{ id: "bodyMass", direction: "JUNK" }] }),
);
+6 -11
View File
@@ -268,17 +268,12 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "integrations.healthkit.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
auditLog("integrations.healthkit.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
+4 -9
View File
@@ -163,15 +163,10 @@ async function commitOcrRows(request: NextRequest) {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.ocr.commit.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {});
auditLog("labs.ocr.commit.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {});
},
});
if (!parsed.ok) return parsed.error;
@@ -85,6 +85,7 @@ import {
consumeForIntake,
restoreForIntake,
} from "@/lib/medications/inventory/consumption";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -109,6 +110,7 @@ function postReq(body: unknown): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
@@ -176,17 +178,15 @@ describe("POST /api/medications/intake/bulk — 422 multi-issue (v1.4.43 W6)", (
);
expect(res.status).toBe(422);
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 };
};
expect(call.data.action).toBe("medications.intake.bulk.validation-failed");
// Through `auditLog()`, which decides whether the refusal is filed.
expect(auditLog).toHaveBeenCalledWith(
"medications.intake.bulk.validation-failed",
expect.anything(),
);
});
it("does not block the 422 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 POST(
postReq({
entries: [{ medicationId: "", scheduledFor: "not-iso" }],
+7 -12
View File
@@ -39,10 +39,10 @@
* Locked contract — see `.planning/v15-ios-handoff/06-ios-responsibilities.md`
* §"Cumulative metrics" sibling section for the SyncMode rationale.
*/
import { auditLog } from "@/lib/auth/audit";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
@@ -198,17 +198,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.bulk.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
auditLog("medications.intake.bulk.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "medications.intake.bulk.invalid",
});
@@ -243,7 +243,7 @@ describe("PUT /api/medications/layout — 422 multi-issue envelope", () => {
expect(res.status).toBe(422);
});
it("writes one deduped audit breadcrumb for repeated 422s", async () => {
it("files no row for an owner's repeated refusals", async () => {
const res1 = await callPut(makeReq({ version: 9 }));
const res2 = await callPut(makeReq({ version: 9 }));
expect(res1.status).toBe(422);
@@ -251,12 +251,9 @@ describe("PUT /api/medications/layout — 422 multi-issue envelope", () => {
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 };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("medication.layout.validation-failed");
// An owner's own refused request is not an audit event: `auditLog`
// keeps it on the wide event and files no row.
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
+7 -11
View File
@@ -11,6 +11,7 @@
* lives on its own `User` column (`medication_list_layout_json`) per
* the per-surface-column convention.
*/
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
@@ -151,17 +152,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medication.layout.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — validation response is the contract, audit row is best-effort */
});
auditLog("medication.layout.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);
}
@@ -100,6 +100,7 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { createTagLinks } from "@/lib/mood/tag-links";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -139,6 +140,7 @@ function existingRow(overrides: {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
@@ -213,17 +215,15 @@ describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
);
expect(res.status).toBe(422);
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 };
};
expect(call.data.action).toBe("mood.bulk.validation-failed");
// Through `auditLog()`, which decides whether the refusal is filed.
expect(auditLog).toHaveBeenCalledWith(
"mood.bulk.validation-failed",
expect.anything(),
);
});
it("does not block the 422 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 POST(
postReq({
entries: [
+7 -12
View File
@@ -24,10 +24,10 @@
* The route authenticates, meters, caps and parses; the per-entry writes live
* in `src/lib/mood/bulk-ingest.ts`.
*/
import { auditLog } from "@/lib/auth/audit";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
@@ -206,17 +206,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood.bulk.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
auditLog("mood.bulk.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "mood.bulk.invalid",
});
@@ -30,7 +30,8 @@
},
"meta": {
"write_bucket": "own",
"issue_count": 2
"issue_count": 2,
"rejected_attempt": "medications.intake.bulk.validation-failed"
},
"warnings": null,
"error": null
@@ -584,24 +585,6 @@
"skipped": 7
},
"ipAddress": null
},
{
"action": "medications.intake.bulk.validation-failed",
"userId": "<id:2>",
"actorUserId": null,
"details": {
"issues": [
{
"path": "entries.0.medicationId",
"code": "too_small"
},
{
"path": "entries.0.takenAt",
"code": "invalid_format"
}
]
},
"ipAddress": null
}
],
"idempotency": []
@@ -567,24 +567,6 @@
"skipped": 7
},
"ipAddress": null
},
{
"action": "medications.intake.bulk.validation-failed",
"userId": "<id:2>",
"actorUserId": null,
"details": {
"issues": [
{
"path": "entries.0.medicationId",
"code": "too_small"
},
{
"path": "entries.0.takenAt",
"code": "invalid_format"
}
]
},
"ipAddress": null
}
],
"idempotency": []
@@ -662,24 +662,6 @@
"skipped": 7
},
"ipAddress": null
},
{
"action": "medications.intake.bulk.validation-failed",
"userId": "<id:4>",
"actorUserId": null,
"details": {
"issues": [
{
"path": "entries.0.medicationId",
"code": "too_small"
},
{
"path": "entries.0.takenAt",
"code": "invalid_format"
}
]
},
"ipAddress": null
}
],
"idempotency": []
@@ -617,24 +617,6 @@
"skipped": 7
},
"ipAddress": null
},
{
"action": "medications.intake.bulk.validation-failed",
"userId": "<id:2>",
"actorUserId": null,
"details": {
"issues": [
{
"path": "entries.0.medicationId",
"code": "too_small"
},
{
"path": "entries.0.takenAt",
"code": "invalid_format"
}
]
},
"ipAddress": null
}
],
"idempotency": []
@@ -619,24 +619,6 @@
"skipped": 7
},
"ipAddress": null
},
{
"action": "medications.intake.bulk.validation-failed",
"userId": "<id:2>",
"actorUserId": null,
"details": {
"issues": [
{
"path": "entries.0.medicationId",
"code": "too_small"
},
{
"path": "entries.0.takenAt",
"code": "invalid_format"
}
]
},
"ipAddress": null
}
],
"idempotency": []
@@ -617,24 +617,6 @@
"skipped": 7
},
"ipAddress": null
},
{
"action": "medications.intake.bulk.validation-failed",
"userId": "<id:2>",
"actorUserId": null,
"details": {
"issues": [
{
"path": "entries.0.medicationId",
"code": "too_small"
},
{
"path": "entries.0.takenAt",
"code": "invalid_format"
}
]
},
"ipAddress": null
}
],
"idempotency": []
@@ -30,7 +30,8 @@
},
"meta": {
"write_bucket": "own",
"issue_count": 2
"issue_count": 2,
"rejected_attempt": "mood.bulk.validation-failed"
},
"warnings": null,
"error": null
@@ -94,7 +94,6 @@ vi.mock("@/lib/medications/outstanding-doses", async (importOriginal) => {
const prisma = getPrismaClient();
const NOW = new Date("2026-10-15T12:00:00.000Z");
const ROUTE = "/api/medications/intake/bulk";
const INVALID_ACTION = "medications.intake.bulk.validation-failed";
let ownerId: string;
let userIds: string[];
@@ -130,8 +129,8 @@ async function syncState() {
}
/**
* An audit row a refusal writes without awaiting it: the schema refusal's
* own, and the narrow token's `auth.bearer.failure`.
* An audit row a refusal writes without awaiting it: the narrow token's
* `auth.bearer.failure`. (An owner's own schema refusal files none.)
*/
async function auditRows(action: string, count: number): Promise<void> {
// Polled on the real timer: the pinned clock stamps the rows read back,
@@ -416,11 +415,9 @@ describe("POST /api/medications/intake/bulk parity", () => {
medicationId: med.daily,
})),
});
await post(
"04b-invalid",
{ entries: [{ medicationId: "", takenAt: "never" }] },
{ settle: () => auditRows(INVALID_ACTION, 1) },
);
await post("04b-invalid", {
entries: [{ medicationId: "", takenAt: "never" }],
});
await post("04c-apple-not-mirrored", {
entries: [
{