From 55a6287069c90235e2644584d9b452eb98c5314b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 16:54:57 +0200 Subject: [PATCH] fix(audit): file every refused request through auditLog() Twelve routes wrote their .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. --- src/__tests__/audit-write-path-guard.test.ts | 61 +++++++++++++++++++ .../[id]/entries/[entryId]/route.ts | 17 ++---- src/app/api/custom-metrics/[id]/route.ts | 23 +++---- .../custom-metrics/__tests__/route.test.ts | 2 + src/app/api/custom-metrics/route.ts | 17 ++---- .../__tests__/route.test.ts | 10 ++- .../dashboard/chart-overlay-prefs/route.ts | 18 +++--- src/app/api/devices/__tests__/route.test.ts | 17 +++--- src/app/api/devices/route.ts | 17 ++---- .../ingest/medication/__tests__/route.test.ts | 17 +++--- src/app/api/ingest/medication/route.ts | 17 ++---- .../insights/layout/__tests__/route.test.ts | 30 +++++---- src/app/api/insights/layout/route.ts | 18 +++--- .../healthkit/__tests__/route.test.ts | 16 ++--- src/app/api/integrations/healthkit/route.ts | 17 ++---- src/app/api/labs/ocr/commit/route.ts | 13 ++-- .../intake/bulk/__tests__/route.test.ts | 16 ++--- src/app/api/medications/intake/bulk/route.ts | 19 +++--- .../layout/__tests__/route.test.ts | 11 ++-- src/app/api/medications/layout/route.ts | 18 +++--- .../mood-entries/bulk/__tests__/route.test.ts | 16 ++--- src/app/api/mood-entries/bulk/route.ts | 19 +++--- ...dication-intake-bulk.04b-invalid.json.snap | 21 +------ ...take-bulk.04c-apple-not-mirrored.json.snap | 18 ------ ...edication-intake-bulk.05a-native.json.snap | 18 ------ ...ion-intake-bulk.05b-narrow-token.json.snap | 18 ------ ...ication-intake-bulk.05c-delegate.json.snap | 18 ------ ...cation-intake-bulk.05d-anonymous.json.snap | 18 ------ .../mood-entries-bulk.05-invalid.json.snap | 3 +- .../medication-intake-bulk.parity.test.ts | 13 ++-- 30 files changed, 217 insertions(+), 319 deletions(-) create mode 100644 src/__tests__/audit-write-path-guard.test.ts diff --git a/src/__tests__/audit-write-path-guard.test.ts b/src/__tests__/audit-write-path-guard.test.ts new file mode 100644 index 000000000..43f6d5bf9 --- /dev/null +++ b/src/__tests__/audit-write-path-guard.test.ts @@ -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 (`.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 = { + "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); + }); +}); diff --git a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts index 2f1d171fe..cd30b6315 100644 --- a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts @@ -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; diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts index 3a2ec7842..2b149bd4d 100644 --- a/src/app/api/custom-metrics/[id]/route.ts +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -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; diff --git a/src/app/api/custom-metrics/__tests__/route.test.ts b/src/app/api/custom-metrics/__tests__/route.test.ts index 57a10098b..50ee33ccf 100644 --- a/src/app/api/custom-metrics/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/__tests__/route.test.ts @@ -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); }); diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index 74bf526ba..dfc9aa12e 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -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; diff --git a/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts b/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts index 65db8fd81..29b6c0ccf 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts index ab5c51a89..ded1da044 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/route.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts @@ -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 */ + }); } }, }); diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index 88c52f32d..ef65576c7 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -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" }), ); diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index 5ad6c53a1..36f24678b 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -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; diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index 421dee241..aae965196 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -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); }); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index f39f89c08..35176e405 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -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; diff --git a/src/app/api/insights/layout/__tests__/route.test.ts b/src/app/api/insights/layout/__tests__/route.test.ts index 3be6182da..341812186 100644 --- a/src/app/api/insights/layout/__tests__/route.test.ts +++ b/src/app/api/insights/layout/__tests__/route.test.ts @@ -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(); + 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. diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts index 640720b0d..3243bfe47 100644 --- a/src/app/api/insights/layout/route.ts +++ b/src/app/api/insights/layout/route.ts @@ -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); } diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 7804fad49..c7d7c6176 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -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" }] }), ); diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index 1db234f6c..62a991514 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -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); } diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index cade37169..e3a2bce86 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -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; diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index bd530c53c..006d9c7ee 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -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" }], diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 80d490cb6..9f0c0ca27 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -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 { 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", }); diff --git a/src/app/api/medications/layout/__tests__/route.test.ts b/src/app/api/medications/layout/__tests__/route.test.ts index 3e02ca491..cc8f500b6 100644 --- a/src/app/api/medications/layout/__tests__/route.test.ts +++ b/src/app/api/medications/layout/__tests__/route.test.ts @@ -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(); }); }); diff --git a/src/app/api/medications/layout/route.ts b/src/app/api/medications/layout/route.ts index 5434fa6c0..84b5a4add 100644 --- a/src/app/api/medications/layout/route.ts +++ b/src/app/api/medications/layout/route.ts @@ -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); } diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index f67b039eb..e77da8c5b 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -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: [ diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 407b3015e..685ee208a 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -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 { 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", }); diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04b-invalid.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04b-invalid.json.snap index 4d2dad010..8d502393a 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04b-invalid.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04b-invalid.json.snap @@ -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": "", - "actorUserId": null, - "details": { - "issues": [ - { - "path": "entries.0.medicationId", - "code": "too_small" - }, - { - "path": "entries.0.takenAt", - "code": "invalid_format" - } - ] - }, - "ipAddress": null } ], "idempotency": [] diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04c-apple-not-mirrored.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04c-apple-not-mirrored.json.snap index 4249b41f7..1b576c2b2 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04c-apple-not-mirrored.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.04c-apple-not-mirrored.json.snap @@ -567,24 +567,6 @@ "skipped": 7 }, "ipAddress": null - }, - { - "action": "medications.intake.bulk.validation-failed", - "userId": "", - "actorUserId": null, - "details": { - "issues": [ - { - "path": "entries.0.medicationId", - "code": "too_small" - }, - { - "path": "entries.0.takenAt", - "code": "invalid_format" - } - ] - }, - "ipAddress": null } ], "idempotency": [] diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap index 6656a318d..7f14348a3 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap @@ -662,24 +662,6 @@ "skipped": 7 }, "ipAddress": null - }, - { - "action": "medications.intake.bulk.validation-failed", - "userId": "", - "actorUserId": null, - "details": { - "issues": [ - { - "path": "entries.0.medicationId", - "code": "too_small" - }, - { - "path": "entries.0.takenAt", - "code": "invalid_format" - } - ] - }, - "ipAddress": null } ], "idempotency": [] diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05b-narrow-token.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05b-narrow-token.json.snap index 216e2e279..8b38a8d26 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05b-narrow-token.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05b-narrow-token.json.snap @@ -617,24 +617,6 @@ "skipped": 7 }, "ipAddress": null - }, - { - "action": "medications.intake.bulk.validation-failed", - "userId": "", - "actorUserId": null, - "details": { - "issues": [ - { - "path": "entries.0.medicationId", - "code": "too_small" - }, - { - "path": "entries.0.takenAt", - "code": "invalid_format" - } - ] - }, - "ipAddress": null } ], "idempotency": [] diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05c-delegate.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05c-delegate.json.snap index ef83ea41b..e30efa5ac 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05c-delegate.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05c-delegate.json.snap @@ -619,24 +619,6 @@ "skipped": 7 }, "ipAddress": null - }, - { - "action": "medications.intake.bulk.validation-failed", - "userId": "", - "actorUserId": null, - "details": { - "issues": [ - { - "path": "entries.0.medicationId", - "code": "too_small" - }, - { - "path": "entries.0.takenAt", - "code": "invalid_format" - } - ] - }, - "ipAddress": null } ], "idempotency": [] diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05d-anonymous.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05d-anonymous.json.snap index 8fff49289..06466d200 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05d-anonymous.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05d-anonymous.json.snap @@ -617,24 +617,6 @@ "skipped": 7 }, "ipAddress": null - }, - { - "action": "medications.intake.bulk.validation-failed", - "userId": "", - "actorUserId": null, - "details": { - "issues": [ - { - "path": "entries.0.medicationId", - "code": "too_small" - }, - { - "path": "entries.0.takenAt", - "code": "invalid_format" - } - ] - }, - "ipAddress": null } ], "idempotency": [] diff --git a/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.05-invalid.json.snap b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.05-invalid.json.snap index a6898965c..6028d3721 100644 --- a/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.05-invalid.json.snap +++ b/tests/integration/routes/parity/__snapshots__/mood-entries-bulk.05-invalid.json.snap @@ -30,7 +30,8 @@ }, "meta": { "write_bucket": "own", - "issue_count": 2 + "issue_count": 2, + "rejected_attempt": "mood.bulk.validation-failed" }, "warnings": null, "error": null diff --git a/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts b/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts index ec3fc649b..f51313028 100644 --- a/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts @@ -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 { // 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: [ {