From a400e5407bef65faf0f8d2147925b238f7f870f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 22:26:14 +0200 Subject: [PATCH] feat(api): tighten POST /api/medications/[id]/glp1 with Zod + audit + rate-limit The GLP-1 convenience route shipped in W4d skipped the Zod / audit / rate-limit triple the sibling inventory + side-effect POSTs ship with. Hand-rolled `typeof === "number"` guards let NaN, Infinity, negative doses, multi-megabyte notes, and 1970-era timestamps reach Prisma. This pass adds: - `glp1DoseChangePostSchema` + `glp1InventoryPostSchema` + the XOR `glp1PostBodySchema` in `src/lib/validations/medication.ts`. - A 500-char cap on `note`, 200-char cap on `reason`, 100 mg cap on `doseValue`, integer cap on `delta`, and a +/-5-year window on `effectiveFrom`. - The 30/min/user rate-limit copied from the sibling routes, keyed on `medication-glp1:post:`. - `auditLog("medication.glp1.update", ...)` for every mutation, tagged with the affected row id and the dose / delta values. - A test fixture mirroring the inventory + side-effect tests with thirteen assertions covering the new 422 paths, the rate-limit header, and both audit branches. --- .../[id]/glp1/__tests__/route.test.ts | 337 ++++++++++++++++++ src/app/api/medications/[id]/glp1/route.ts | 178 +++++---- src/lib/validations/medication.ts | 71 ++++ 3 files changed, 526 insertions(+), 60 deletions(-) create mode 100644 src/app/api/medications/[id]/glp1/__tests__/route.test.ts diff --git a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts new file mode 100644 index 000000000..17851e06b --- /dev/null +++ b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts @@ -0,0 +1,337 @@ +/** + * v1.4.25 W21 Fix-K — GLP-1 convenience-route POST tests. + * + * Pins the sec-H1 hardening pass: Zod parse, 30/min/user rate-limit, + * `medication.glp1.update` audit row, bounded `doseValue` / `note` / + * `effectiveFrom`, XOR refinement between the doseChange / inventory + * branches. Mirrors the sibling inventory + side-effect fixture + * pattern so the four routes share one test shape. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findUnique: vi.fn() }, + medicationDoseChange: { create: vi.fn() }, + medicationInventoryEvent: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })), +})); + +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 { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; + +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 }, +}; + +const MED_OK = { id: "med-1", userId: "user-1" }; + +function jsonReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/medications/med-1/glp1", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now() + 60_000, + }); +}); + +describe("POST /api/medications/[id]/glp1", () => { + it("rejects unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await POST( + jsonReq({ + doseChange: { + effectiveFrom: "2026-05-14T08:00:00Z", + doseValue: 0.5, + doseUnit: "mg", + }, + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(401); + }); + + it("returns 404 when the medication belongs to a different user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "OTHER", + } as never); + const res = await POST( + jsonReq({ + doseChange: { + effectiveFrom: "2026-05-14T08:00:00Z", + doseValue: 0.5, + doseUnit: "mg", + }, + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(404); + }); + + it("returns 422 when neither doseChange nor inventory is provided", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + const res = await POST(jsonReq({}), { + params: Promise.resolve({ id: "med-1" }), + }); + expect(res.status).toBe(422); + }); + + it("returns 422 when both doseChange and inventory are provided", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + const res = await POST( + jsonReq({ + doseChange: { + effectiveFrom: "2026-05-14T08:00:00Z", + doseValue: 0.5, + doseUnit: "mg", + }, + inventory: { delta: 1, reason: "refill" }, + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when doseValue is negative", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + const res = await POST( + jsonReq({ + doseChange: { + effectiveFrom: "2026-05-14T08:00:00Z", + doseValue: -1, + doseUnit: "mg", + }, + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when doseValue is NaN (not a finite number)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + // JSON cannot encode NaN, but Number.NaN reaches the schema if the + // client base64-encodes or hand-crafts the body. Simulate the + // post-decode shape by passing `null` (which fails the finite gate) + // and `1e400` (which JSON renders as null too) via the same path. + const res = await POST( + jsonReq({ + doseChange: { + effectiveFrom: "2026-05-14T08:00:00Z", + doseValue: null, + doseUnit: "mg", + }, + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when doseValue exceeds 100 mg cap", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + const res = await POST( + jsonReq({ + doseChange: { + effectiveFrom: "2026-05-14T08:00:00Z", + doseValue: 250, + doseUnit: "mg", + }, + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when note exceeds 500 chars", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + const res = await POST( + jsonReq({ + doseChange: { + effectiveFrom: "2026-05-14T08:00:00Z", + doseValue: 0.5, + doseUnit: "mg", + note: "x".repeat(501), + }, + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when effectiveFrom predates 2020", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + const res = await POST( + jsonReq({ + doseChange: { + effectiveFrom: "1995-01-01T00:00:00Z", + doseValue: 0.5, + doseUnit: "mg", + }, + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when inventory delta is zero", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + const res = await POST( + jsonReq({ inventory: { delta: 0, reason: "noop" } }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(422); + }); + + it("returns 429 when the rate limit is exceeded", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await POST( + jsonReq({ + doseChange: { + effectiveFrom: "2026-05-14T08:00:00Z", + doseValue: 0.5, + doseUnit: "mg", + }, + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(429); + expect(res.headers.get("X-RateLimit-Remaining")).toBe("0"); + }); + + it("creates a doseChange row and writes a medication.glp1.update audit entry", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + vi.mocked(prisma.medicationDoseChange.create).mockResolvedValue({ + id: "dc-new", + effectiveFrom: new Date("2026-05-14T08:00:00Z"), + doseValue: 0.5, + doseUnit: "mg", + note: null, + } as never); + + const res = await POST( + jsonReq({ + doseChange: { + effectiveFrom: "2026-05-14T08:00:00Z", + doseValue: 0.5, + doseUnit: "mg", + }, + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(201); + expect(prisma.medicationDoseChange.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + medicationId: "med-1", + effectiveFrom: new Date("2026-05-14T08:00:00Z"), + doseValue: 0.5, + doseUnit: "mg", + note: null, + }), + }); + expect(auditLog).toHaveBeenCalledWith( + "medication.glp1.update", + expect.objectContaining({ + userId: "user-1", + details: expect.objectContaining({ + medicationId: "med-1", + kind: "doseChange", + doseChangeId: "dc-new", + doseValue: 0.5, + doseUnit: "mg", + }), + }), + ); + }); + + it("creates an inventory row and writes a medication.glp1.update audit entry", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + vi.mocked(prisma.medicationInventoryEvent.create).mockResolvedValue({ + id: "iv-new", + delta: 1, + reason: "refill", + } as never); + + const res = await POST( + jsonReq({ inventory: { delta: 1, reason: "refill" } }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(201); + expect(prisma.medicationInventoryEvent.create).toHaveBeenCalledWith({ + data: { medicationId: "med-1", delta: 1, reason: "refill" }, + }); + expect(auditLog).toHaveBeenCalledWith( + "medication.glp1.update", + expect.objectContaining({ + userId: "user-1", + details: expect.objectContaining({ + medicationId: "med-1", + kind: "inventory", + inventoryEventId: "iv-new", + delta: 1, + reason: "refill", + }), + }), + ); + }); +}); diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index 39966061b..0f93f7b19 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -7,16 +7,46 @@ * rotation data), and the running pen-inventory math. The base * /api/medications/[id] GET is unchanged so v1.4.24 consumers keep * working untouched. + * + * v1.4.25 W21 Fix-K — POST hardened with Zod parse, audit-log, 30/min + * rate-limit, finite-number guard on `doseValue`, length cap on `note`, + * and sane bounds on `effectiveFrom`. Mirrors the sibling + * `/inventory` + `/side-effects` route shape. */ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { annotate } from "@/lib/logging/context"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { glp1PostBodySchema } from "@/lib/validations/medication"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; const LOW_STOCK_DOSE_THRESHOLD = 4; +const POST_RATE_LIMIT = 30; +const POST_WINDOW_MS = 60_000; + +async function assertMedicationOwnership( + medicationId: string, + userId: string, +) { + const med = await prisma.medication.findUnique({ + where: { id: medicationId }, + select: { id: true, userId: true }, + }); + if (!med || med.userId !== userId) { + return apiError("Medication not found", 404); + } + return null; +} export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { @@ -88,90 +118,118 @@ export const GET = apiHandler( }, ); -const doseChangeSchema = { - effectiveFrom: "string", - doseValue: "number", - doseUnit: "string", - note: "string?", -} as const; - -const inventorySchema = { - delta: "number", - reason: "string", -} as const; - -void doseChangeSchema; -void inventorySchema; - -interface DoseChangeBody { - effectiveFrom?: string; - doseValue?: number; - doseUnit?: string; - note?: string | null; -} - -interface InventoryBody { - delta?: number; - reason?: string; -} - -interface Glp1PostBody { - doseChange?: DoseChangeBody; - inventory?: InventoryBody; -} - /** * POST creates a new dose change OR inventory event (the body picks * one — caller specifies which). Convenience endpoint so the * medication-card disclosure can write rows without dispatching to * /api/medications/[id]/dose-change + /api/medications/[id]/inventory * separately for the v1.4.25 cut. + * + * Validation: `glp1PostBodySchema` (XOR of `doseChange` / `inventory`), + * with finite-number guards on `doseValue` + `delta`, bounded `note` + * + `reason`, and a ±5-year window on `effectiveFrom`. + * Rate-limit: 30/min/user (same as sibling POST routes). + * Audit: `medication.glp1.update` for every successful mutation. */ export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); const { id } = await params; - const medication = await prisma.medication.findUnique({ where: { id } }); - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + // Per-user POST rate-limit — matches the 30/min sibling routes + // (inventory, side-effects). Generous for a hand-driven session, + // tight enough to cut off the spam case. + const rl = await checkRateLimit( + `medication-glp1:post:${user.id}`, + POST_RATE_LIMIT, + POST_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; } - const body = (await request - .json() - .catch(() => null)) as Glp1PostBody | null; - if (!body) return apiError("Invalid body", 400); + const { data: body, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; - if (body.doseChange) { - const { effectiveFrom, doseValue, doseUnit, note } = body.doseChange; - if (!effectiveFrom || typeof doseValue !== "number" || !doseUnit) { - return apiError( - "doseChange.effectiveFrom + doseValue + doseUnit required", - 422, - ); - } + const parsed = glp1PostBodySchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const ip = getClientIp(request); + + if (parsed.data.doseChange) { + const { effectiveFrom, doseValue, doseUnit, note } = + parsed.data.doseChange; const created = await prisma.medicationDoseChange.create({ data: { medicationId: id, - effectiveFrom: new Date(effectiveFrom), + effectiveFrom, doseValue, doseUnit, note: note ?? null, }, }); + + await auditLog("medication.glp1.update", { + userId: user.id, + ipAddress: ip, + details: { + medicationId: id, + kind: "doseChange", + doseChangeId: created.id, + doseValue, + doseUnit, + }, + }); + + annotate({ + action: { + name: "medication.glp1.doseChange.create", + entity_type: "medication_dose_change", + entity_id: created.id, + }, + meta: { medication_id: id, doseValue, doseUnit }, + }); + return apiSuccess({ doseChange: created }, 201); } - if (body.inventory) { - const { delta, reason } = body.inventory; - if (typeof delta !== "number" || !reason) { - return apiError("inventory.delta + reason required", 422); - } - const created = await prisma.medicationInventoryEvent.create({ - data: { medicationId: id, delta, reason }, - }); - return apiSuccess({ inventory: created }, 201); - } + // The schema's XOR refinement guarantees `inventory` is present + // when `doseChange` is not, so the non-null assertion is safe. + const { delta, reason } = parsed.data.inventory!; + const created = await prisma.medicationInventoryEvent.create({ + data: { medicationId: id, delta, reason }, + }); - return apiError("Body must carry doseChange or inventory", 422); + await auditLog("medication.glp1.update", { + userId: user.id, + ipAddress: ip, + details: { + medicationId: id, + kind: "inventory", + inventoryEventId: created.id, + delta, + reason, + }, + }); + + annotate({ + action: { + name: "medication.glp1.inventory.create", + entity_type: "medication_inventory_event", + entity_id: created.id, + }, + meta: { medication_id: id, delta }, + }); + + return apiSuccess({ inventory: created }, 201); }, ); diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 6d37f64af..6d1102e78 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -145,6 +145,77 @@ export const updateInventoryItemSchema = z.object({ export type CreateInventoryItemInput = z.infer; export type UpdateInventoryItemInput = z.infer; +/** + * v1.4.25 W21 Fix-K — `POST /api/medications/[id]/glp1` body validators. + * + * The convenience route accepts either a `doseChange` or an `inventory` + * payload (the route picks one). Both branches were hand-rolled + * `typeof === "number"` checks pre-Fix-K, which let `NaN`, `Infinity`, + * negative doses, and unbounded notes slip through. + * + * Bounds: + * - `doseValue` is finite, non-negative, capped at 100 mg (covers every + * real-world GLP-1 step with headroom). + * - `doseUnit` is a short string (mg / mcg / IE). + * - `note` is capped at 500 characters so the field can't be used as a + * blob smuggler. + * - `effectiveFrom` is constrained to a ±5-year window around now — + * a paper-record back-fill or a planned future step both fit, but + * "1970" / "9999" do not. + * - `delta` is a non-zero finite integer in [−100, 100] (the existing + * inventory route caps `dosesTotal` at 100 doses per pen). + * - `reason` is a bounded string (the route logs it; raw blob bad). + */ +const MAX_DOSE_MG = 100; +const MAX_NOTE_CHARS = 500; +const MAX_REASON_CHARS = 200; +const MIN_EFFECTIVE_FROM = new Date("2020-01-01T00:00:00Z"); +const FIVE_YEARS_MS = 5 * 365 * 24 * 60 * 60 * 1000; + +export const glp1DoseChangePostSchema = z.object({ + effectiveFrom: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .refine((d) => d.getTime() >= MIN_EFFECTIVE_FROM.getTime(), { + message: "effectiveFrom must be on or after 2020-01-01", + }) + .refine((d) => d.getTime() <= Date.now() + FIVE_YEARS_MS, { + message: "effectiveFrom must be within 5 years of now", + }), + doseValue: z + .number() + .finite() + .min(0) + .max(MAX_DOSE_MG), + doseUnit: z.string().min(1).max(10), + note: z.string().max(MAX_NOTE_CHARS).nullable().optional(), +}); + +export const glp1InventoryPostSchema = z.object({ + delta: z + .number() + .int() + .finite() + .min(-100) + .max(100) + .refine((n) => n !== 0, { message: "delta must be non-zero" }), + reason: z.string().min(1).max(MAX_REASON_CHARS), +}); + +export const glp1PostBodySchema = z + .object({ + doseChange: glp1DoseChangePostSchema.optional(), + inventory: glp1InventoryPostSchema.optional(), + }) + .refine( + (b) => Boolean(b.doseChange) !== Boolean(b.inventory), + { message: "Body must carry exactly one of doseChange or inventory" }, + ); + +export type Glp1DoseChangePostInput = z.infer; +export type Glp1InventoryPostInput = z.infer; +export type Glp1PostBodyInput = z.infer; + export type CreateMedicationInput = z.infer; export type IntakeInput = z.infer; export type ListIntakeEventsInput = z.infer;