/** * Regression guard — an image that would blow the decode budget must be refused, not * allocated, and the container must survive it. * * The compression worker sets `max_alloc = 256 MiB`, but that budget was inert: reading the * EXIF orientation tag requires `ImageReader::into_decoder()`, which skips the * `limits.reserve(decoder.total_bytes())` that `decode()` performs, and nothing else enforces * it (the JPEG decoder's `set_limits` only checks support and dimensions). So the only real * bound was the 12000px per-axis cap — leaving 12000x12000 decodable at 412 MiB, and two * concurrent decodes at 824 MiB against a 1 GiB container. * * That mattered acutely because bumping DERIVATIVES_REV makes the first boot after a deploy * re-decode the whole gallery two at a time: an OOM kill there restarts the container, which * re-runs the backfill — a boot loop. * * This suite could never have caught it, because until now the e2e app container had NO * memory limit at all while production is capped at 1 GiB. The cap is mirrored in * docker-compose.test.yml so this test means something. * * Fixture: 11000x9000 = 99 MP, 568 KiB on disk. Deliberately UNDER the per-axis cap, so the * axis check cannot be what rejects it — 283 MiB decoded against a 256 MiB budget. */ import { test, expect } from '../../fixtures/test'; import { uploadRaw } from '../../helpers/upload-client'; import { BASE } from '../../helpers/env'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; const HUGE = join(process.cwd(), 'fixtures', 'media', 'huge-99mp.jpg'); const SAMPLE = join(process.cwd(), 'fixtures', 'media', 'sample.jpg'); test.describe('Upload — an oversized image is refused, not allocated', () => { test('a 99 MP upload fails compression gracefully and the backend stays up', async ({ guest, db, }) => { test.setTimeout(90_000); const g = await guest('BombThrower'); // The upload itself is accepted — 568 KiB is well within the body cap. The rejection // happens in the compression worker, where the decode budget lives. const res = await uploadRaw(g.jwt, readFileSync(HUGE), { filename: 'huge.jpg', contentType: 'image/jpeg', caption: 'zu gross', }); expect(res.status, 'a 568 KiB file is a legitimate upload').toBe(201); const { id } = (await res.json()) as { id: string }; // It must land in 'failed', not 'done' — and must get there, rather than the container // dying mid-decode and leaving it stuck in 'processing' forever. await expect .poll(() => db.compressionStatus(id), { timeout: 60_000, intervals: [500] }) .toBe('failed'); // The whole point: the process is still alive. An OOM kill would have taken the backend // down here, and Docker would have restarted it. const health = await fetch(`${BASE}/health`); expect(health.status, 'the backend must have survived the oversized decode').toBe(200); // And it is still doing useful work afterwards — not wedged or restarting. const ok = await uploadRaw(g.jwt, readFileSync(SAMPLE), { filename: 'after.jpg', contentType: 'image/jpeg', }); expect(ok.status).toBe(201); const after = (await ok.json()) as { id: string }; await expect.poll(() => db.compressionStatus(after.id), { timeout: 30_000 }).toBe('done'); }); test('two oversized uploads at once still leave the container alive', async ({ guest, db }) => { // The concurrent case is the one that actually OOM'd: `compression_concurrency` is 2, so // two decodes overlap. Under the old behaviour this pair peaked near the container cap. test.setTimeout(90_000); const g = await guest('BombThrower2'); const bytes = readFileSync(HUGE); const [a, b] = await Promise.all([ uploadRaw(g.jwt, bytes, { filename: 'huge-a.jpg', contentType: 'image/jpeg' }), uploadRaw(g.jwt, bytes, { filename: 'huge-b.jpg', contentType: 'image/jpeg' }), ]); expect([a.status, b.status]).toEqual([201, 201]); const ids = [((await a.json()) as { id: string }).id, ((await b.json()) as { id: string }).id]; for (const id of ids) { await expect .poll(() => db.compressionStatus(id), { timeout: 60_000, intervals: [500] }) .toBe('failed'); } const health = await fetch(`${BASE}/health`); expect(health.status, 'two concurrent oversized decodes must not kill the backend').toBe(200); }); });