From 6bab2ce774660fd5187fe5dc259baa912791827e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 14:00:38 +0200 Subject: [PATCH] fix(documents): serialise the upload quota gate per user Concurrent uploads all read the same pre-insert usage SUM, so a burst could overshoot the per-user quota by rate-limit x cap in one window. A transaction-scoped advisory lock keyed on the user id serialises the sum-and-insert; uploads by different users never queue on each other. Integration probes pin both races: four parallel uploads against a two-file quota land exactly two, and two racing identical uploads collapse onto one row via the partial unique index. Also aligns the round-trip integration test with the serve-route CSP posture: the proxy carve-out owns the response CSP, the route sets none. --- src/app/api/documents/inbound/route.ts | 12 +++++ tests/integration/documents-vault.test.ts | 59 ++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index 4a0f09462..fc5b43be6 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -261,6 +261,18 @@ async function postUpload(request: Request): Promise { let document: SerialisableDocument; try { document = await prisma.$transaction(async (tx) => { + // Serialise the quota gate per user: without this, N concurrent + // uploads all read the same SUM before any of them commits and the + // quota can be overshot by up to N × cap in one burst. The advisory + // lock is transaction-scoped (released on commit/rollback) and keyed + // on the user id, so uploads by different users never queue on each + // other. + // (`pg_advisory_xact_lock` returns void, which the client cannot + // deserialize as a column — selecting FROM it yields a plain int row.) + await tx.$queryRaw` + SELECT 1 AS locked + FROM pg_advisory_xact_lock(hashtextextended('documents-quota:' || ${user.id}, 0)) + `; const rows = await tx.$queryRaw>` SELECT COALESCE(SUM(byte_size), 0)::bigint AS used FROM inbound_documents diff --git a/tests/integration/documents-vault.test.ts b/tests/integration/documents-vault.test.ts index 0325a9f68..a8833b6b0 100644 --- a/tests/integration/documents-vault.test.ts +++ b/tests/integration/documents-vault.test.ts @@ -176,7 +176,11 @@ describe("document vault — upload + serve round-trip", () => { expect(orig.headers.get("Content-Type")).toBe("image/png"); expect(orig.headers.get("Content-Disposition")).toContain("inline"); expect(orig.headers.get("X-Content-Type-Options")).toBe("nosniff"); - expect(orig.headers.get("Content-Security-Policy")).toBe("sandbox"); + // The response CSP is owned by the proxy's serve-route carve-out + // (`default-src 'none'; frame-ancestors 'self'` — middleware headers win + // over route headers), so the route itself sets none. Pinned by + // `src/__tests__/proxy-document-serve-framing.test.ts`. + expect(orig.headers.get("Content-Security-Policy")).toBeNull(); const served = Buffer.from(await orig.arrayBuffer()); expect(served.equals(PNG_1X1)).toBe(true); }); @@ -263,6 +267,37 @@ describe("document vault — policy error contract", () => { expect(body.meta.usedBytes).toBe(PNG_1X1.byteLength); expect(body.meta.quotaBytes).toBe(PNG_1X1.byteLength + 10); }); + + it("cannot be overshot by concurrent uploads racing the quota gate", async () => { + // Four DISTINCT same-sized files fired in parallel against a quota that + // fits exactly two. Without the per-user advisory lock in the upload + // transaction every request reads the same pre-insert SUM and all four + // land — a burst could overshoot the quota by rate-limit × cap. + const user = await seedVaultUser("vault-quota-race"); + const files = Array.from({ length: 4 }, (_, i) => { + const filler = Buffer.alloc(2048, i + 1); + return Buffer.concat([Buffer.from("%PDF-1.7\n"), filler]); + }); + const size = files[0].byteLength; + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { documentQuotaBytes: BigInt(size * 2 + 10) }, + }); + const { post } = await routes(); + + const results = await Promise.all( + files.map((bytes, i) => post(uploadRequest(bytes, `race-${i}.pdf`))), + ); + const statuses = results.map((r) => r.status).sort(); + expect(statuses).toEqual([201, 201, 413, 413]); + + const rows = await getPrismaClient().inboundDocument.findMany({ + where: { userId: user.id }, + select: { byteSize: true }, + }); + const stored = rows.reduce((sum, r) => sum + r.byteSize, 0); + expect(stored).toBeLessThanOrEqual(size * 2 + 10); + }); }); describe("document vault — dedupe + idempotency", () => { @@ -282,6 +317,28 @@ describe("document vault — dedupe + idempotency", () => { expect(await getPrismaClient().inboundDocument.count()).toBe(1); }); + it("collapses two racing identical uploads onto one row", async () => { + // Same bytes fired twice in parallel: the fast-path dedupe check misses + // (neither row exists yet), so the partial unique index must close the + // race — one 201, one 200 + meta.duplicate pointing at the winner. + await seedVaultUser("vault-dedupe-race"); + const { post } = await routes(); + + const [a, b] = await Promise.all([ + post(uploadRequest(PNG_1X1, "race-a.png")), + post(uploadRequest(PNG_1X1, "race-b.png")), + ]); + const statuses = [a.status, b.status].sort(); + expect(statuses).toEqual([200, 201]); + const bodies = await Promise.all([a.json(), b.json()]); + const winner = bodies.find((x) => x.meta?.duplicate !== true); + const loser = bodies.find((x) => x.meta?.duplicate === true); + expect(winner).toBeDefined(); + expect(loser).toBeDefined(); + expect(loser!.data.id).toBe(winner!.data.id); + expect(await getPrismaClient().inboundDocument.count()).toBe(1); + }); + it("dedupes per user, not globally", async () => { await seedVaultUser("vault-user-a"); const { post } = await routes();