From 53a7992a914e82c691072e5276f78333452e3057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 17:03:10 +0200 Subject: [PATCH] fix(audit): log source-priority + doctor-report-prefs writes Both PUT endpoints persist values into User.*Json columns that shape downstream analytics output (source-priority drives the aggregator's source pick on every read; doctor-report-prefs gates PDF section inclusion including the mood-data toggle). Neither wrote an audit-log entry, so a silent compromise or unintended client write was invisible. Mirror the timezone-route pattern: capture the previous shape, write the new value, then `auditLog` with previous + next + client IP. Adds the missing source-priority route test alongside the new audit case in the doctor-report-prefs suite. --- .../__tests__/route.test.ts | 34 +++++ .../api/auth/me/doctor-report-prefs/route.ts | 17 ++- .../source-priority/__tests__/route.test.ts | 138 ++++++++++++++++++ src/app/api/auth/me/source-priority/route.ts | 23 ++- 4 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 src/app/api/auth/me/source-priority/__tests__/route.test.ts diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts index 31e171556..13e2159ab 100644 --- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts @@ -33,6 +33,7 @@ vi.mock("next/headers", () => ({ import { GET, PUT } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs"; const SESSION_OK = { @@ -154,4 +155,37 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { expect(res.status).toBe(401); expect(prisma.user.update).not.toHaveBeenCalled(); }); + + // ── Case 5 — audit trail (v1.4.25 W10 reconcile security M-3) ────── + it("writes an audit-log entry with the previous and new pref shape", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const current = { + bp: true, + weight: true, + pulse: true, + bmi: true, + mood: false, + compliance: true, + sleep: true, + }; + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + doctorReportPrefsJson: current, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PUT as (r: Request) => Promise)( + mkPut({ mood: true }), + ); + expect(res.status).toBe(200); + expect(auditLog).toHaveBeenCalledWith( + "user.doctor-report-prefs.update", + expect.objectContaining({ + userId: "user-1", + details: expect.objectContaining({ + previous: current, + next: expect.objectContaining({ mood: true }), + }), + }), + ); + }); }); diff --git a/src/app/api/auth/me/doctor-report-prefs/route.ts b/src/app/api/auth/me/doctor-report-prefs/route.ts index cdb8728ab..9aeea0438 100644 --- a/src/app/api/auth/me/doctor-report-prefs/route.ts +++ b/src/app/api/auth/me/doctor-report-prefs/route.ts @@ -17,8 +17,9 @@ * layer when `mood = false` — the data never leaves the DB. */ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { doctorReportPrefsSchema, @@ -81,6 +82,20 @@ export const PUT = apiHandler(async (req: Request) => { }, }); + // v1.4.25 W10 reconcile (security M-3): record the previous + new + // pref shape in the audit log. Doctor-Report pref toggles + // (especially `mood: true`) widen the PDF surface; without an + // audit trail a silent compromise (or unintended client write) + // is invisible. Mirrors the timezone-route audit pattern. + await auditLog("user.doctor-report-prefs.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + previous: current?.doctorReportPrefsJson ?? null, + next: merged, + }, + }); + annotate({ action: { name: "auth.me.doctor-report-prefs.put" }, meta: { diff --git a/src/app/api/auth/me/source-priority/__tests__/route.test.ts b/src/app/api/auth/me/source-priority/__tests__/route.test.ts new file mode 100644 index 000000000..fa1980baa --- /dev/null +++ b/src/app/api/auth/me/source-priority/__tests__/route.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +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 { GET, PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const }, +}; + +function mkPut(body: unknown): Request { + return new Request("http://localhost/api/auth/me/source-priority", { + method: "PUT", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/auth/me/source-priority", () => { + it("rejects unauthenticated requests with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (GET as () => Promise)(); + expect(res.status).toBe(401); + }); + + it("returns the resolved shape with defaults when the column is null", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + } as never); + + const res = await (GET as () => Promise)(); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: Record }; + // Resolver always emits the keys the analytics layer needs. + expect(env.data).toHaveProperty("weight"); + expect(env.data).toHaveProperty("metricPriority"); + expect(env.data).toHaveProperty("deviceTypePriority"); + }); +}); + +describe("PUT /api/auth/me/source-priority", () => { + it("rejects unauthenticated requests with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (PUT as (r: Request) => Promise)( + mkPut({ weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] }), + ); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("persists the partial shape", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const body = { weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] }; + const res = await (PUT as (r: Request) => Promise)(mkPut(body)); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { sourcePriorityJson: body }, + }); + }); + + it("rejects an invalid shape with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await (PUT as (r: Request) => Promise)( + mkPut({ weight: "not-an-array" }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + // ── v1.4.25 W10 reconcile (security M-3) ── + it("writes an audit-log entry with the previous and new shape", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const previousShape = { + weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"], + }; + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: previousShape, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const body = { weight: ["APPLE_HEALTH", "WITHINGS", "MANUAL"] }; + const res = await (PUT as (r: Request) => Promise)(mkPut(body)); + expect(res.status).toBe(200); + expect(auditLog).toHaveBeenCalledWith( + "user.source-priority.update", + expect.objectContaining({ + userId: "user-1", + details: expect.objectContaining({ + previous: previousShape, + next: body, + }), + }), + ); + }); +}); diff --git a/src/app/api/auth/me/source-priority/route.ts b/src/app/api/auth/me/source-priority/route.ts index 388a43110..5d9055cec 100644 --- a/src/app/api/auth/me/source-priority/route.ts +++ b/src/app/api/auth/me/source-priority/route.ts @@ -16,8 +16,9 @@ * the read path is cheap (one column) — no caching layer to flush. */ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { parseSourcePriority, @@ -55,6 +56,17 @@ export const PUT = apiHandler(async (req: Request) => { throw new HttpError(422, "source-priority.body.invalid_shape"); } + // v1.4.25 W10 reconcile (security M-3): capture the previous shape + // before overwriting so the audit-log entry records the actual + // delta. Source-priority drives every aggregator's source pick, + // so a silent compromise (or accidental client write) was + // previously invisible — mirroring the timezone-route audit + // pattern closes the gap. + const before = await prisma.user.findUnique({ + where: { id: user.id }, + select: { sourcePriorityJson: true }, + }); + // Persist the partial form — missing keys read as defaults via // `parseSourcePriority`. Storing only the user-edited subset keeps // the Json blob narrow and future-proofs the shape: when a new @@ -65,6 +77,15 @@ export const PUT = apiHandler(async (req: Request) => { data: { sourcePriorityJson: parsed.data }, }); + await auditLog("user.source-priority.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + previous: before?.sourcePriorityJson ?? null, + next: parsed.data, + }, + }); + annotate({ action: { name: "auth.me.source-priority.put" }, meta: { keys: Object.keys(parsed.data).length },