diff --git a/e2e/docker-compose.sim.yml b/e2e/docker-compose.sim.yml index d7885ab..940182e 100644 --- a/e2e/docker-compose.sim.yml +++ b/e2e/docker-compose.sim.yml @@ -82,6 +82,8 @@ services: COMPRESSION_WORKER_CONCURRENCY: '2' # Production disables comments for this event (docker-compose.yml, product decision). COMMENTS_ENABLED: 'false' + # Mirrors the deployed setting so the harness can audit against the real gate. + KEEPSAKE_ENABLED: ${SIM_KEEPSAKE:-true} # The ONE deviation from production: enables /admin/__truncate so the harness can # reset between runs. Never set on the real box. EVENTSNAP_TEST_MODE: '1' @@ -102,7 +104,7 @@ services: frontend: # Shipped release image; `git diff v0.17.6 HEAD -- frontend/` is empty. - image: registry.mc02.dev/eventsnap/frontend:v0.17.6 + image: ${SIM_FE_IMAGE:-registry.mc02.dev/eventsnap/frontend:v0.17.6} cpuset: *cpuset depends_on: - app diff --git a/e2e/loadtest/acceptance-audit.mjs b/e2e/loadtest/acceptance-audit.mjs new file mode 100644 index 0000000..52981cf --- /dev/null +++ b/e2e/loadtest/acceptance-audit.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +/** + * Acceptance audit: push EVERY file in the pool through the real upload validator + * and report exactly what the server refuses, and why. + * + * Answers one question — "with these settings, which of my photos would be turned + * away?" — and answers it empirically rather than by reasoning about limits. Size + * caps are only one of the paths that can refuse a file: the magic-byte allowlist, + * the decode budget (12000 px axis / 256 MiB alloc, both code constants), the disk + * gate and the per-user quota all reject too, and only the running server knows the + * interaction between them. + * + * Deliberately NOT a load test: no personas, no viewers, no think-time. It measures + * admission, so it does not wait for the compression backlog to drain. + * + * SIM_UPLOADERS=8 node e2e/loadtest/acceptance-audit.mjs + */ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BASE = process.env.SIM_BASE ?? 'http://localhost:3102'; +const API = `${BASE}/api/v1`; +const POOL = process.env.SIM_POOL_DIR ?? '/tmp/eventsnap-realpool'; +const META = process.env.SIM_POOL_META ?? '/tmp/eventsnap-pool.json'; +const ADMIN_PW = process.env.SIM_ADMIN_PW ?? 'admin-test-pw'; +const CONC = parseInt(process.env.AUDIT_CONC ?? '4', 10); +// Spread across a few accounts, as a real event does — a single uploader would hit +// the per-user quota and hourly limit for reasons unrelated to the files themselves. +const UPLOADERS = parseInt(process.env.SIM_UPLOADERS ?? '8', 10); + +const j = async (path, opts = {}) => { + const res = await fetch(`${API}${path}`, opts); + const text = await res.text(); + let body; + try { + body = text ? JSON.parse(text) : undefined; + } catch { + body = text; + } + return { status: res.status, body }; +}; + +async function main() { + const meta = JSON.parse(await readFile(META, 'utf8')); + console.log(`[pool] ${meta.length} files, ${(meta.reduce((a, f) => a + f.bytes, 0) / 1e9).toFixed(2)} GB`); + + const admin = ( + await j('/admin/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: ADMIN_PW }), + }) + ).body.jwt; + + const cfg = (await j('/admin/config', { headers: { Authorization: `Bearer ${admin}` } })).body; + const shown = [ + 'max_image_size_mb', + 'max_video_size_mb', + 'upload_rate_per_hour', + 'storage_quota_enabled', + 'quota_enabled', + ]; + console.log(`[config] ${shown.map((k) => `${k}=${cfg[k]}`).join(' ')}`); + const stats = (await j('/admin/stats', { headers: { Authorization: `Bearer ${admin}` } })).body; + console.log( + `[disk] ${(stats.disk_free_bytes / 1e9).toFixed(1)} GB free of ${(stats.disk_total_bytes / 1e9).toFixed(1)} GB` + ); + + const guests = []; + for (let i = 0; i < UPLOADERS; i++) { + const r = await j('/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ display_name: `Audit ${i} ${randomUUID().slice(0, 4)}` }), + }); + guests.push(r.body.jwt); + } + console.log(`[join] ${guests.length} uploaders\n`); + + const results = []; + let done = 0; + const queue = [...meta]; + const t0 = Date.now(); + + const worker = async (slot) => { + while (queue.length) { + const f = queue.shift(); + if (!f) break; + const jwt = guests[slot % guests.length]; + let buf; + try { + buf = await readFile(join(POOL, f.name)); + } catch (e) { + results.push({ ...f, status: -1, msg: `read error: ${e}` }); + continue; + } + const form = new FormData(); + form.append('file', new Blob([buf], { type: f.magic }), f.name); + form.append('client_upload_id', randomUUID()); + let status, body; + try { + const res = await fetch(`${API}/upload`, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + body: form, + }); + status = res.status; + const t = await res.text(); + try { + body = JSON.parse(t); + } catch { + body = t; + } + } catch (e) { + status = 0; + body = { message: String(e).slice(0, 80) }; + } + results.push({ + name: f.name, + bytes: f.bytes, + magic: f.magic, + w: f.w, + h: f.h, + status, + code: body?.code ?? body?.error, + msg: status >= 400 ? String(body?.message ?? '').slice(0, 110) : undefined, + }); + if (++done % 100 === 0) { + const ok = results.filter((r) => r.status === 201).length; + console.log( + ` ${done}/${meta.length} accepted ${ok} refused ${done - ok} (${((Date.now() - t0) / 1000).toFixed(0)}s)` + ); + } + } + }; + await Promise.all(Array.from({ length: CONC }, (_, i) => worker(i))); + + // ── Report ──────────────────────────────────────────────────────────────── + const ok = results.filter((r) => r.status === 201); + const bad = results.filter((r) => r.status !== 201); + const byReason = {}; + for (const r of bad) { + const key = `${r.status} ${r.msg ?? r.code ?? '?'}`; + (byReason[key] ??= []).push(r); + } + + console.log('\n' + '═'.repeat(74)); + console.log('ACCEPTANCE AUDIT'); + console.log('═'.repeat(74)); + console.log( + `accepted ${ok.length}/${results.length} (${(ok.reduce((a, r) => a + r.bytes, 0) / 1e9).toFixed(2)} GB)` + ); + console.log(`refused ${bad.length}\n`); + for (const [reason, rows] of Object.entries(byReason).sort((a, b) => b[1].length - a[1].length)) { + const sizes = rows.map((r) => r.bytes / 1024 / 1024); + console.log(` ${rows.length} x ${reason}`); + console.log( + ` sizes ${Math.min(...sizes).toFixed(1)}–${Math.max(...sizes).toFixed(1)} MB · types ${[...new Set(rows.map((r) => r.magic))].join(', ')}` + ); + console.log(` e.g. ${rows.slice(0, 3).map((r) => r.name).join(', ')}`); + } + if (!bad.length) console.log(' ✓ nothing was refused'); + + const outDir = join(__dirname, 'results'); + await mkdir(outDir, { recursive: true }); + const out = join(outDir, `acceptance-${new Date().toISOString().replace(/[:.]/g, '-')}.json`); + await writeFile(out, JSON.stringify({ config: cfg, stats, results }, null, 2)); + console.log(`\nfull detail → ${out}`); + console.log('═'.repeat(74)); +} + +main().catch((e) => { + console.error('audit failed:', e); + process.exit(1); +});