mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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 },
|
||||
|
||||
Reference in new issue
Block a user