From 311adcfdda7b226f322162eb8a26d19c5b6cd745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 14:18:56 +0200 Subject: [PATCH] Refuse a manual satisfy of a screening reminder server-side A Vorsorge screening (PHQ-9, GAD-7, WHO-5, SCI) resolves only from the score row a completed check-in writes, never from a manual done. The web and iOS clients enforce this by routing a screening to the check-in page instead of offering the done action, but the satisfy and complete routes trusted that gate and would stamp any reminder, screening included. A crafted POST could therefore mark a screening satisfied with no assessment behind it. Both routes now reject a screening reminder with 409 before the shared satisfy primitive runs, mirroring the client rule as defense in depth. Typed numeric reminders keep their manual satisfy, which is intentional and covered. The satisfy route gains its first route test alongside the new guard case on complete. --- .../[id]/complete/__tests__/route.test.ts | 29 ++++ .../[id]/complete/route.ts | 14 ++ .../[id]/satisfy/__tests__/route.test.ts | 145 ++++++++++++++++++ .../[id]/satisfy/route.ts | 14 ++ 4 files changed, 202 insertions(+) create mode 100644 src/app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts diff --git a/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts index 5a48a0bf1..c6aa2b7ca 100644 --- a/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts +++ b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts @@ -144,4 +144,33 @@ describe("POST /api/measurement-reminders/[id]/complete", () => { expect(res.status).toBe(404); expect(satisfyReminderMock).not.toHaveBeenCalled(); }); + + it("refuses a screening reminder with 409 and never satisfies (defense-in-depth)", async () => { + // A crafted completion of a PHQ-9 screening: the client never offers this + // (it routes to /mental-wellbeing), and a screening may only resolve from + // the server-written score row. The route must not let it read as done. + findFirstMock.mockResolvedValue({ ...ROW, measurementType: "PHQ9_SCORE" }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(409); + expect(satisfyReminderMock).not.toHaveBeenCalled(); + }); + + it("still allows a manual completion of a typed numeric reminder", async () => { + // Typed numeric reminders (a BP row here) legitimately support a manual + // "complete"; the screening guard must not catch them. + findFirstMock.mockResolvedValue(ROW); // measurementType: BLOOD_PRESSURE_SYS + satisfyReminderMock.mockResolvedValue({ + satisfied: true, + nextDueAt: new Date("2026-06-25T07:00:00Z"), + }); + findUniqueOrThrowMock.mockResolvedValue({ + ...ROW, + lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"), + }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(200); + expect(satisfyReminderMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/app/api/measurement-reminders/[id]/complete/route.ts b/src/app/api/measurement-reminders/[id]/complete/route.ts index ce3a8d1fb..23fcc71b6 100644 --- a/src/app/api/measurement-reminders/[id]/complete/route.ts +++ b/src/app/api/measurement-reminders/[id]/complete/route.ts @@ -29,6 +29,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import { isScreeningReminderType } from "@/lib/validations/measurement-reminders"; type RouteParams = { params: Promise<{ id: string }> }; @@ -53,6 +54,19 @@ export const POST = apiHandler( return apiError("Measurement reminder not found", 404); } + // Defense-in-depth: a screening reminder (PHQ-9 / GAD-7 / WHO-5 / SCI) + // resolves ONLY from the server-written *_SCORE row a completed check-in + // produces — never from a manual "complete." The client routes a screening + // to `/mental-wellbeing` instead of offering this action, so a completion + // landing here is a crafted request; refusing it keeps a screening from + // reading as completed with no assessment behind it. + if (isScreeningReminderType(existing.measurementType)) { + return apiError( + "Screening reminders resolve from a completed check-in, not a manual completion", + 409, + ); + } + const userRow = await prisma.user.findUnique({ where: { id: user.id }, select: { timezone: true }, diff --git a/src/app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts new file mode 100644 index 000000000..796291334 --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts @@ -0,0 +1,145 @@ +/** + * v1.17.1 — manual "Erledigt" (satisfy) route. + * + * Covers: a free-text Vorsorge resolves through the shared primitive, + * owner-scoped 404 on a cross-user / tombstoned id, and the defense-in-depth + * screening refusal (a screening reminder may only resolve from the + * server-written score row, never a crafted manual satisfy). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + requireRecordAuth: vi.fn(async () => ({ + user: { id: "u1", locale: "en" }, + actor: { id: "u1", locale: "en" }, + grantId: null, + })), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +const satisfyReminderMock = vi.fn(); +vi.mock("@/lib/measurement-reminders/satisfy", () => ({ + satisfyReminder: (...args: unknown[]) => satisfyReminderMock(...args), +})); + +const findFirstMock = vi.fn(); +const findUserMock = vi.fn(); +const findUniqueOrThrowMock = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + measurementReminder: { + findFirst: (...a: unknown[]) => findFirstMock(...a), + findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrowMock(...a), + }, + user: { findUnique: (...a: unknown[]) => findUserMock(...a) }, + }, +})); + +import { POST } from "../route"; + +const ROW = { + id: "r1", + userId: "u1", + label: "Blutbild", + measurementType: null, // free-text Vorsorge (resolves only on a manual satisfy) + intervalDays: 365, + rrule: null, + anchorDate: null, + endsOn: null, + origin: "VORSORGE", + notifyHour: 9, + location: null, + nextDueAt: new Date("2026-06-25T07:00:00Z"), + lastSatisfiedAt: null, + enabled: true, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-18T00:00:00Z"), + deletedAt: null, +}; + +function makeRequest(): NextRequest { + return new NextRequest( + "http://localhost/api/measurement-reminders/r1/satisfy", + { method: "POST" }, + ); +} + +const params = { params: Promise.resolve({ id: "r1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + findUserMock.mockResolvedValue({ timezone: "Europe/Berlin" }); +}); + +describe("POST /api/measurement-reminders/[id]/satisfy", () => { + it("satisfies a free-text Vorsorge via the shared primitive", async () => { + findFirstMock.mockResolvedValue(ROW); + satisfyReminderMock.mockResolvedValue({ + satisfied: true, + nextDueAt: new Date("2027-06-25T07:00:00Z"), + }); + findUniqueOrThrowMock.mockResolvedValue({ + ...ROW, + lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"), + }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.error).toBeNull(); + expect(body.data.id).toBe("r1"); + expect(satisfyReminderMock).toHaveBeenCalledTimes(1); + }); + + it("is owner-scoped: a cross-user reminder 404s and never satisfies", async () => { + findFirstMock.mockResolvedValue({ ...ROW, userId: "someone-else" }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(404); + expect(satisfyReminderMock).not.toHaveBeenCalled(); + }); + + it("404s a tombstoned / missing reminder", async () => { + findFirstMock.mockResolvedValue(null); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(404); + expect(satisfyReminderMock).not.toHaveBeenCalled(); + }); + + it("refuses a screening reminder with 409 and never satisfies (defense-in-depth)", async () => { + findFirstMock.mockResolvedValue({ ...ROW, measurementType: "GAD7_SCORE" }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(409); + expect(satisfyReminderMock).not.toHaveBeenCalled(); + }); + + it("still allows a manual satisfy of a typed numeric reminder", async () => { + findFirstMock.mockResolvedValue({ + ...ROW, + measurementType: "BLOOD_PRESSURE_SYS", + }); + satisfyReminderMock.mockResolvedValue({ + satisfied: true, + nextDueAt: new Date("2026-07-02T07:00:00Z"), + }); + findUniqueOrThrowMock.mockResolvedValue({ + ...ROW, + measurementType: "BLOOD_PRESSURE_SYS", + lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"), + }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(200); + expect(satisfyReminderMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts index dc24728fb..7af4ccbb7 100644 --- a/src/app/api/measurement-reminders/[id]/satisfy/route.ts +++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts @@ -15,6 +15,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import { isScreeningReminderType } from "@/lib/validations/measurement-reminders"; type RouteParams = { params: Promise<{ id: string }> }; @@ -39,6 +40,19 @@ export const POST = apiHandler( return apiError("Measurement reminder not found", 404); } + // Defense-in-depth: a screening reminder (PHQ-9 / GAD-7 / WHO-5 / SCI) + // resolves ONLY from the server-written *_SCORE row a completed check-in + // produces — never from a manual "done." The client already routes a + // screening to `/mental-wellbeing` instead of offering this action, so a + // satisfy landing here is a crafted request; refusing it keeps a screening + // from reading as completed with no assessment behind it. + if (isScreeningReminderType(existing.measurementType)) { + return apiError( + "Screening reminders resolve from a completed check-in, not a manual satisfy", + 409, + ); + } + const userRow = await prisma.user.findUnique({ where: { id: user.id }, select: { timezone: true },