mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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);
|
||||
|
||||
Reference in new issue
Block a user