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 },