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:<userId>`.
- `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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 22:26:14 +02:00
1 parent 1d5dcfb8b1
commit a400e5407b
3 files changed
+526 -60

No files matched your search

@@ -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",
}),
}),
);
});
});
+118 -60
View File
@@ -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);
},
);
+71
View File
@@ -145,6 +145,77 @@ export const updateInventoryItemSchema = z.object({
export type CreateInventoryItemInput = z.infer<typeof createInventoryItemSchema>;
export type UpdateInventoryItemInput = z.infer<typeof updateInventoryItemSchema>;
/**
* 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<typeof glp1DoseChangePostSchema>;
export type Glp1InventoryPostInput = z.infer<typeof glp1InventoryPostSchema>;
export type Glp1PostBodyInput = z.infer<typeof glp1PostBodySchema>;
export type CreateMedicationInput = z.infer<typeof createMedicationSchema>;
export type IntakeInput = z.infer<typeof intakeSchema>;
export type ListIntakeEventsInput = z.infer<typeof listIntakeEventsSchema>;