From 167cc3afa2cda25f98b138251ca76130bc33a461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 29 May 2026 00:38:32 +0200 Subject: [PATCH] feat(meds): rate-limit the bulk-delete intake endpoint Match the glp1 POST cap (30 calls per minute per user) so a caller firing repeated 500-id deletes cannot pin the recompute worker. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../bulk-delete/__tests__/route.test.ts | 42 +++++++++++++++++++ .../[id]/intake/bulk-delete/route.ts | 23 ++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts index f642898c7..b06e008f3 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts @@ -35,6 +35,15 @@ vi.mock("@/lib/medications/route-guards", () => ({ assertMedicationOwnership: vi.fn().mockResolvedValue(null), })); +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/rollups/medication-compliance-rollups", () => ({ dayKeyForScheduledFor: vi.fn((d: Date) => { const iso = d.toISOString(); @@ -72,6 +81,7 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { checkRateLimit } from "@/lib/rate-limit"; import { dayKeyForScheduledFor, recomputeMedicationComplianceForDay, @@ -96,6 +106,11 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(assertMedicationOwnership).mockResolvedValue(null); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now() + 60_000, + }); vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone: "Europe/Berlin", } as never); @@ -190,3 +205,30 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { }); }); }); + +describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit", () => { + it("returns 429 when the per-user cap is exhausted", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await POST( + postWithBody({ eventIds: ["evt-a"] }), + ROUTE_PARAMS, + ); + expect(res.status).toBe(429); + expect(res.headers.get("X-RateLimit-Remaining")).toBe("0"); + expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.deleteMany).not.toHaveBeenCalled(); + }); + + it("scopes the bucket key to the calling user", async () => { + await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS); + expect(checkRateLimit).toHaveBeenCalledWith( + "medication-intake-bulk-delete:user-1", + 30, + 60_000, + ); + }); +}); diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index b622c5bcd..938508447 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -3,11 +3,13 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { + apiError, apiSuccess, getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { bulkDeleteIntakeEventsSchema } from "@/lib/validations/medication"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { @@ -19,6 +21,13 @@ import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; +// v1.5.5 F-1 H-6 — match the glp1 POST shape so a caller firing +// repeated 500-id bulk deletes cannot pin the per-dayKey recompute +// path. 30/min/user is generous for a hand-driven multi-select and +// tight enough to cut off the spam case. +const BULK_DELETE_RATE_LIMIT = 30; +const BULK_DELETE_WINDOW_MS = 60_000; + /** * v1.5.5 D-3 §9.5 + §11 — bulk-delete N intake rows in one transaction * so the detail-page intake-history preview can offer multi-select → @@ -54,6 +63,20 @@ export const POST = apiHandler( const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; + // v1.5.5 F-1 H-6 — per-user cap matches the glp1 POST so a caller + // firing repeated 500-id bulk deletes cannot pin the per-dayKey + // recompute path. + const rl = await checkRateLimit( + `medication-intake-bulk-delete:${user.id}`, + BULK_DELETE_RATE_LIMIT, + BULK_DELETE_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + const { data: body, error: jsonError } = await safeJson(request); if (jsonError) return jsonError; const parsed = bulkDeleteIntakeEventsSchema.safeParse(body);