mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
This commit is contained in:
1 parent
23e371965b
commit
6bab2ce774
2 files changed
+70
-1
No files matched your search
@@ -261,6 +261,18 @@ async function postUpload(request: Request): Promise<Response> {
|
||||
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<Array<{ used: bigint }>>`
|
||||
SELECT COALESCE(SUM(byte_size), 0)::bigint AS used
|
||||
FROM inbound_documents
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user