diff --git a/src/app/api/labs/ocr/commit/__tests__/route.test.ts b/src/app/api/labs/ocr/commit/__tests__/route.test.ts index e77e0b794..3ab2468ad 100644 --- a/src/app/api/labs/ocr/commit/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/commit/__tests__/route.test.ts @@ -21,6 +21,15 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +const rateLimitMock = vi.hoisted(() => ({ allowed: true })); +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: rateLimitMock.allowed, + remaining: rateLimitMock.allowed ? 299 : 0, + resetAt: new Date(Date.now() + 60_000), + })), +})); const tzMock = vi.hoisted(() => ({ zone: "UTC" })); vi.mock("@/lib/tz/resolver", async (importOriginal) => ({ ...(await importOriginal()), @@ -62,6 +71,7 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -90,6 +100,7 @@ function postReq(body: unknown): NextRequest { beforeEach(() => { vi.resetAllMocks(); tzMock.zone = "UTC"; + rateLimitMock.allowed = true; vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(enqueueReminderSatisfy).mockResolvedValue(undefined as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); @@ -198,3 +209,24 @@ describe("POST /api/labs/ocr/commit dedup across the user's day", () => { expect(prisma.labResult.create).toHaveBeenCalledTimes(1); }); }); + +describe("POST /api/labs/ocr/commit write ceiling", () => { + it("charges the shared per-account record-write bucket", async () => { + const res = await POST(postReq({ rows: [numericRow(95)] })); + expect(res.status).toBe(200); + expect(checkRecordWriteRateLimit).toHaveBeenCalledWith("user-1"); + }); + + it("answers 429 record_write.rate_limited and writes nothing once exhausted", async () => { + rateLimitMock.allowed = false; + const res = await POST(postReq({ rows: [numericRow(95)] })); + expect(res.status).toBe(429); + const body = (await res.json()) as { + error: string; + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("record_write.rate_limited"); + expect(prisma.labResult.create).not.toHaveBeenCalled(); + expect(resolveOrMintBiomarker).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 1066edb69..7e0a00d17 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -20,6 +20,7 @@ import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { + apiError, apiSuccess, getClientIp, returnAllZodIssues, @@ -30,6 +31,7 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { withIdempotency } from "@/lib/idempotency"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; @@ -136,6 +138,17 @@ function inBatchKey(row: OcrCommitRow, tz: string): string { async function commitOcrRows(request: NextRequest) { const { user } = await requireAuth(); + // Shared per-account write ceiling, the same bucket every other lab write + // charges (`POST /api/labs`, `POST /api/biomarkers`). A commit can write a + // whole report's rows and mint markers on the way, so it was the one lab + // write a looping client could repeat without any ceiling. + const writeRl = await checkRecordWriteRateLimit(user.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } + const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 256 * 1024, });