/** * Storage-quota ENFORCEMENT — the branch that runs in production and, until now, in zero tests. * * `quota_enabled` and `storage_quota_enabled` both default to TRUE in production * (upload.rs: `config::get_bool(.., "quota_enabled", true)`), but global-setup turns them off and * the per-test TRUNCATE re-asserts them as 'false' before EVERY test. So the entire quota path was * dead under test: the pre-check, the `QuotaExceeded` rejection, and — most importantly — the * atomic increment * * UPDATE "user" SET total_upload_bytes = total_upload_bytes + $2 * WHERE id = $1 AND total_upload_bytes + $2 <= $3 * * whose enforcement is `rows_affected() == 0`. That UPDATE is the ONLY thing stopping two * concurrent uploads from the same guest (phone + laptop, or a double-tap) from both passing the * stale pre-check and both committing. The five existing unit tests cover `quota_limit_bytes()` — * the arithmetic that computes the number — which gave the appearance of coverage while the * enforcement had none. * * Blast radius if it regresses: one guest silently fills the disk, and then NOBODY at the wedding * can upload. Those photos don't exist anywhere else. * * These tests steer the limit via `quota_tolerance` (limit = floor(free_disk * tolerance / active)) * rather than hoping a real disk happens to be nearly full. */ import { test, expect } from '../../fixtures/test'; import { join } from 'node:path'; import { readFileSync } from 'node:fs'; import { uploadPausedMidStream } from '../../helpers/upload-client'; import { BASE } from '../../helpers/env'; const SAMPLE_BYTES = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')); const SIZE = SAMPLE_BYTES.byteLength; interface Quota { enabled: boolean; used_bytes: number; limit_bytes: number | null; active_uploaders: number; free_disk_bytes: number | null; } const quotaOf = async (jwt: string): Promise => (await fetch(BASE + '/api/v1/me/quota', { headers: { Authorization: `Bearer ${jwt}` } })).json(); function upload(jwt: string, name: string) { const form = new FormData(); form.append('file', new Blob([SAMPLE_BYTES], { type: 'image/jpeg' }), name); form.append('content_type', 'image/jpeg'); return fetch(BASE + '/api/v1/upload', { method: 'POST', headers: { Authorization: `Bearer ${jwt}` }, body: form, }); } /** * Pick a `quota_tolerance` that makes the per-user ceiling land on `targetBytes`. * limit = floor(free_disk * tolerance / max(active, 1)) ⇒ tolerance = target * active / free. */ async function setLimitTo( api: any, adminToken: string, jwt: string, targetBytes: number ): Promise { const q = await quotaOf(jwt); expect( q.free_disk_bytes, 'the disk must be readable, else quota fails OPEN and proves nothing' ).toBeTruthy(); const active = Math.max(q.active_uploaders, 1); const tolerance = (targetBytes * active) / (q.free_disk_bytes as number); await api.patchConfig(adminToken, { quota_tolerance: tolerance.toExponential(12) }); const after = await quotaOf(jwt); expect(after.enabled).toBe(true); return after.limit_bytes as number; } test.describe('Upload — storage quota enforcement', () => { test.beforeEach(async ({ api, adminToken }) => { await api.patchConfig(adminToken, { quota_enabled: 'true', storage_quota_enabled: 'true', }); }); test('an upload that would exceed the quota is rejected with 413 and stores nothing', async ({ api, adminToken, guest, }) => { const g = await guest('QuotaOver'); // Ceiling below one file: the very first upload must be refused. const limit = await setLimitTo(api, adminToken, g.jwt, Math.floor(SIZE / 2)); expect(limit).toBeLessThan(SIZE); const res = await upload(g.jwt, 'too-big.jpg'); expect(res.status, 'over-quota upload must be refused').toBe(413); expect((await res.json()).error).toBe('quota_exceeded'); // Nothing was accounted, and nothing reached the feed. expect((await quotaOf(g.jwt)).used_bytes).toBe(0); const feed = await ( await fetch(BASE + '/api/v1/feed', { headers: { Authorization: `Bearer ${g.jwt}` } }) ).json(); expect(feed.uploads).toHaveLength(0); }); test('an upload within the quota still succeeds (the guard is not simply "always no")', async ({ api, adminToken, guest, }) => { const g = await guest('QuotaUnder'); await setLimitTo(api, adminToken, g.jwt, SIZE * 4); expect((await upload(g.jwt, 'fine.jpg')).status).toBe(201); expect((await quotaOf(g.jwt)).used_bytes).toBe(SIZE); }); test('two CONCURRENT uploads cannot BOTH slip past the quota (atomic increment)', async ({ api, adminToken, guest, }) => { const g = await guest('QuotaRacer'); // Room for exactly ONE file. const limit = await setLimitTo(api, adminToken, g.jwt, Math.floor(SIZE * 1.5)); expect(limit).toBeGreaterThanOrEqual(SIZE); expect(limit).toBeLessThan(SIZE * 2); // Both uploads are held OPEN MID-BODY, and this is the entire point of the test. // // The handler loads the user row (and therefore snapshots `total_upload_bytes`) at its very // FIRST line — before it streams a single byte of the body. So holding both requests inside // the body loop guarantees both are carrying the same stale `total = 0`, and both pre-checks // will conclude "this fits". Only the conditional UPDATE can then stop the second. // // Firing them with a plain `Promise.all` does NOT reproduce this: the first request completes // and commits before the second even reads the user row, so the *pre-check* rejects the second // and the test passes with the atomic guard deleted. (It did. That is how this test was caught // being vacuous, and why it is written this way.) const head = SAMPLE_BYTES.subarray(0, 32); const tail = SAMPLE_BYTES.subarray(32); const a = uploadPausedMidStream(g.jwt, head, tail, { filename: 'race-a.jpg' }); const b = uploadPausedMidStream(g.jwt, head, tail, { filename: 'race-b.jpg' }); // Both are now past the pre-flight checks and sitting in the body loop, each holding total = 0. await Promise.all([a.checkPassed, b.checkPassed]); a.finish(); b.finish(); const [ra, rb] = await Promise.all([a.response, b.response]); const statuses = [ra.status, rb.status].sort(); expect( statuses, `exactly one upload may win the race; got ${JSON.stringify(statuses)} — if BOTH are 201 the atomic guard is gone and a single guest can overrun the disk, after which NOBODY at the party can upload` ).toEqual([201, 413]); // The invariant that actually matters: accounting never exceeds the ceiling. const q = await quotaOf(g.jwt); expect(q.used_bytes).toBe(SIZE); expect(q.used_bytes).toBeLessThanOrEqual(limit); }); test('GET /me/quota reports the live usage the UI shows the guest', async ({ api, adminToken, guest, }) => { // Zero test hits before this — and it is the source of the "X von Y MB genutzt" widget. const g = await guest('QuotaWidget'); await setLimitTo(api, adminToken, g.jwt, SIZE * 10); const before = await quotaOf(g.jwt); expect(before.enabled).toBe(true); expect(before.used_bytes).toBe(0); expect((await upload(g.jwt, 'one.jpg')).status).toBe(201); const after = await quotaOf(g.jwt); expect(after.used_bytes).toBe(SIZE); expect(after.limit_bytes).toBeGreaterThan(after.used_bytes); }); });