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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-09 14:18:56 +02:00
1 parent 53359d292c
commit 311adcfdda
4 files changed
+202

No files matched your search

@@ -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);
});
});
@@ -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 },
@@ -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: <T extends (...args: unknown[]) => 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);
});
});
@@ -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 },