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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-29 00:53:23 +02:00
1 parent ff334ff10e
commit 167cc3afa2
2 files changed
+65

No files matched your search

@@ -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,
);
});
});
@@ -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);