#!/usr/bin/env node /** * EventSnap EVENT SIMULATION — a real wedding, compressed. * * Differs from `driver.mjs` (a pipeline benchmark) in three ways that matter: * * 1. REAL CONTENT. Uploads come from a pool of actual wedding photos/videos — * unedited, straight off real cameras and phones, including the HEIC files * and 25 MB RAW-ish JPEGs that the app is supposed to REFUSE. Those refusals * are part of the test, not noise to be filtered out. * * 2. PERSONAS, not a uniform load. ~100 viewers and ~50 uploaders, each with a * behaviour profile, a device, a join time and a session length. A casual * guest who posts four photos and leaves generates a completely different * request mix than a photographer dumping 130, and both differ from a kiosk * that holds one SSE connection for the whole night. * * 3. RATE LIMITS STAY ON. `driver.mjs` disabled them because it ran every guest * from one IP. Almost every limit that matters here is PER USER, not per IP * (upload 100/h/user, feed 60/min/user, social 120/min/user), and those are * exactly as real for 150 synthetic sessions as for 150 phones. Leaving them * on is what lets the abuse personas prove the defences work. The per-IP * limits (join, recover, pin-reset) ARE distorted by the single source IP — * that distortion is measured and reported rather than configured away. * * Nothing else is changed from the shipping config: compression concurrency 2, * DB pool 15, quotas on, comments off — the same values docker-compose.yml pins. * * Run it pinned OFF the container's cores, or the load generator competes with * the thing it is measuring: * * taskset -c 2-11 node e2e/loadtest/event-sim.mjs */ import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { randomUUID } from 'node:crypto'; const execFileAsync = promisify(execFile); const __dirname = dirname(fileURLToPath(import.meta.url)); // ── Config ─────────────────────────────────────────────────────────────────── const cfg = { base: process.env.SIM_BASE ?? 'http://localhost:3102', poolDir: process.env.SIM_POOL_DIR ?? '/tmp/eventsnap-realpool', poolMeta: process.env.SIM_POOL_META ?? '/tmp/eventsnap-pool.json', // The real event is 10 h. We compress it into this many seconds of wall clock. windowSec: int('SIM_WINDOW_SEC', 1200), realEventHours: 10, viewers: int('SIM_VIEWERS', 100), uploaders: int('SIM_UPLOADERS', 50), drainTimeoutSec: int('SIM_DRAIN_TIMEOUT_SEC', 1500), adminPw: process.env.SIM_ADMIN_PW ?? 'admin-test-pw', truncate: process.env.SIM_TRUNCATE !== '0', appContainer: process.env.SIM_APP_CONTAINER ?? 'eventsnap-sim-app-1', dbContainer: process.env.SIM_DB_CONTAINER ?? 'eventsnap-sim-db-1', feContainer: process.env.SIM_FE_CONTAINER ?? 'eventsnap-sim-frontend-1', caddyContainer: process.env.SIM_CADDY_CONTAINER ?? 'eventsnap-sim-caddy-1', outDir: process.env.SIM_OUT_DIR ?? join(__dirname, 'results'), abuse: process.env.SIM_ABUSE !== '0', // Smoke runs cap the pool so a 90 s rehearsal doesn't push 7 GB through the box. maxFiles: int('SIM_MAX_FILES', 0), }; function int(name, def) { const v = process.env[name]; return v === undefined ? def : parseInt(v, 10); } const API = `${cfg.base}/api/v1`; const now = () => Date.now(); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const rand = (a, b) => a + Math.floor(Math.random() * (b - a + 1)); const frand = (a, b) => a + Math.random() * (b - a); const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; function shuffle(a) { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } /** Real seconds → simulated seconds. A 10 h event in a 20 min window is 30x. */ const TIME_SCALE = () => (cfg.realEventHours * 3600) / cfg.windowSec; /** A think-time of `sec` real seconds, compressed. */ const think = (sec) => sleep(Math.max(15, (sec * 1000) / TIME_SCALE())); // ── Device profiles ────────────────────────────────────────────────────────── // User-Agent plus the behavioural consequences of the device: a phone on party // wifi drops its SSE stream, a kiosk on ethernet does not. const DEVICES = [ { name: 'iPhone 15 / Safari', ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', mobile: true, dropRate: 0.06, latencyMs: [40, 220], }, { name: 'Pixel 8 / Chrome', ua: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36', mobile: true, dropRate: 0.05, latencyMs: [40, 200], }, { name: 'Galaxy S21 / Samsung Internet', ua: 'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/23.0 Chrome/115.0.0.0 Mobile Safari/537.36', mobile: true, dropRate: 0.09, latencyMs: [60, 400], }, { name: 'iPad / Safari', ua: 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', mobile: true, dropRate: 0.03, latencyMs: [30, 150], }, { name: 'MacBook / Safari', ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', mobile: false, dropRate: 0.01, latencyMs: [20, 80], }, { name: 'ThinkPad / Firefox', ua: 'Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0', mobile: false, dropRate: 0.01, latencyMs: [20, 80], }, ]; const KIOSK_DEVICE = { name: 'Kiosk display / Chrome', ua: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', mobile: false, dropRate: 0.0, latencyMs: [5, 25], }; // ── Content ────────────────────────────────────────────────────────────────── const CAPTIONS = [ 'Was für ein magischer Tag 💍', 'Der erste Tanz 🕺', 'Prost! 🥂', 'Die Torte war ein Traum 🍰', 'Feuerwerk 🎆', 'Beste Freunde 💕', 'Was für eine Stimmung! 🎉', 'Details 🌸', 'Sonnenuntergang über dem See 🌅', 'Die Tanzfläche brennt 🔥', 'Standesamt ❤️', 'Sektempfang im Garten', 'Ich heule gleich 😭', 'Brautstrauß-Weitwurf!', 'Gruppenbild — alle mal herschauen!', null, null, null, null, ]; const TAGS = [ 'hochzeit', 'liebe', 'party', 'tanzen', 'natur', 'feier', 'freunde', 'dessert', 'brautpaar', 'sektempfang', 'firstdance', 'torte', ]; // ── Metrics ────────────────────────────────────────────────────────────────── const M = { uploads: [], // {status, code, ms, bytes, id, persona, guest, endTs, file} feed: [], // {ms, status} media: [], // {ms, status, kind} likes: [], // {ms, status} joins: [], // {ms, status} misc: [], // {op, ms, status, code} abuse: [], // {name, status, code, expected, pass, note} sseEvents: 0, sseNewUpload: 0, sseProcessed: new Map(), // upload_id -> ts first seen sseErrors: 0, sseReconnects: 0, sseResyncs: 0, resources: [], serverErrors: [], // any 5xx, anywhere — each one is a finding timeline: [], // notable events }; const uploadEndTs = new Map(); /** * Terminal `upload.compression_status` values. Anything else ('pending', * 'processing') is still in the worker queue. Getting this set wrong is not a * cosmetic error: 'failed' counted as in-flight makes the drain loop wait out its * whole timeout and report a backlog that actually cleared. */ const TERMINAL_COMPRESSION = new Set(['done', 'failed', 'error']); function note(msg) { const t = ((now() - T0) / 1000).toFixed(0); M.timeline.push({ tSec: +t, msg }); console.log(`[t+${t}s] ${msg}`); } // ── HTTP ───────────────────────────────────────────────────────────────────── async function http(path, { method = 'GET', token, json, ua, raw, signal } = {}) { const headers = {}; if (token) headers.Authorization = `Bearer ${token}`; if (ua) headers['User-Agent'] = ua; if (json !== undefined) headers['Content-Type'] = 'application/json'; const t0 = now(); let res, body, err; try { res = await fetch(`${API}${path}`, { method, headers, body: json !== undefined ? (raw ? json : JSON.stringify(json)) : undefined, signal, }); } catch (e) { return { status: 0, ms: now() - t0, err: String(e).slice(0, 120) }; } const ms = now() - t0; if (res.status !== 204) { const text = await res.text(); try { body = text.length ? JSON.parse(text) : undefined; } catch { body = text; } } const code = body && typeof body === 'object' ? (body.code ?? body.error) : undefined; if (res.status >= 500) { M.serverErrors.push({ path, method, status: res.status, code, body: JSON.stringify(body).slice(0, 300), tSec: Math.round((now() - T0) / 1000), }); } return { status: res.status, ms, body, code, err }; } /** Media fetch (preview/thumbnail/display/original) — measured, body discarded. */ async function fetchMedia(id, kind, ua) { const t0 = now(); try { const res = await fetch(`${API}/upload/${id}/${kind}`, { headers: ua ? { 'User-Agent': ua } : {} }); const buf = await res.arrayBuffer(); const rec = { ms: now() - t0, status: res.status, kind, bytes: buf.byteLength }; M.media.push(rec); if (res.status >= 500) M.serverErrors.push({ path: `/upload/{id}/${kind}`, method: 'GET', status: res.status }); return rec; } catch (e) { const rec = { ms: now() - t0, status: 0, kind, err: String(e).slice(0, 80) }; M.media.push(rec); return rec; } } const adminLogin = () => http('/admin/login', { method: 'POST', json: { password: cfg.adminPw } }).then((r) => { if (r.status !== 200) throw new Error(`admin login ${r.status}: ${JSON.stringify(r.body)}`); return r.body.jwt; }); // ── Upload ─────────────────────────────────────────────────────────────────── async function uploadFile(guest, file, { caption, hashtags, mime, filename, abortAfterMs } = {}) { let buf; try { buf = await readFile(join(cfg.poolDir, file.name)); } catch (e) { return { status: -1, err: `read ${file.name}: ${e}` }; } return uploadBuffer(guest, buf, { caption, hashtags, mime: mime ?? file.magic, filename: filename ?? file.name, abortAfterMs, file: file.name, poolBytes: file.bytes, }); } async function uploadBuffer( guest, buf, { caption, hashtags, mime, filename, abortAfterMs, file, poolBytes, extraFields } = {} ) { const form = new FormData(); form.append('file', new Blob([buf], { type: mime ?? 'image/jpeg' }), filename ?? 'photo.jpg'); if (caption !== undefined && caption !== null) form.append('caption', caption); if (hashtags) form.append('hashtags', hashtags); form.append('client_upload_id', randomUUID()); for (const [k, v] of Object.entries(extraFields ?? {})) form.append(k, v); const ctrl = new AbortController(); let aborter; if (abortAfterMs) aborter = setTimeout(() => ctrl.abort(), abortAfterMs); const t0 = now(); let res, body; try { res = await fetch(`${API}/upload`, { method: 'POST', headers: { Authorization: `Bearer ${guest.jwt}`, 'User-Agent': guest.device.ua }, body: form, signal: ctrl.signal, }); const text = await res.text(); try { body = text.length ? JSON.parse(text) : undefined; } catch { body = text; } } catch (e) { if (aborter) clearTimeout(aborter); return { status: 0, ms: now() - t0, aborted: true, err: String(e).slice(0, 80), bytes: buf.length, file, }; } if (aborter) clearTimeout(aborter); const endTs = now(); const code = body && typeof body === 'object' ? (body.code ?? body.error) : undefined; if (res.status >= 500) M.serverErrors.push({ path: '/upload', method: 'POST', status: res.status, code, body: JSON.stringify(body).slice(0, 300), file, tSec: Math.round((endTs - T0) / 1000), }); const rec = { status: res.status, code, ms: endTs - t0, endTs, bytes: buf.length, poolBytes, id: res.status === 201 || res.status === 200 ? body?.id : undefined, persona: guest.persona, guest: guest.idx, file, msg: res.status >= 400 ? String(body?.message ?? body).slice(0, 120) : undefined, }; if (rec.id) uploadEndTs.set(rec.id, endTs); return rec; } // ── SSE ────────────────────────────────────────────────────────────────────── class Sse { constructor(guest) { this.g = guest; this.stop = false; this.ctrl = null; } start() { this._loop(); return this; } async _loop() { while (!this.stop) { try { const tkt = await http('/stream/ticket', { method: 'POST', token: this.g.jwt, ua: this.g.device.ua }); if (tkt.status !== 200) { if (this.stop) return; await sleep(1000); continue; } this.ctrl = new AbortController(); const res = await fetch(`${API}/stream?ticket=${tkt.body.ticket}`, { headers: { Accept: 'text/event-stream', 'User-Agent': this.g.device.ua }, signal: this.ctrl.signal, }); if (!res.ok || !res.body) throw new Error(`stream ${res.status}`); const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ''; while (!this.stop) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let i; while ((i = buf.indexOf('\n\n')) !== -1) { this._frame(buf.slice(0, i)); buf = buf.slice(i + 2); } // Flaky devices drop the stream mid-event, like a phone losing wifi. if (Math.random() < this.g.device.dropRate / 40) { this.ctrl.abort(); break; } } } catch { if (this.stop) return; M.sseReconnects++; await sleep(300 + Math.random() * 1200); } } } _frame(frame) { let event = 'message'; let data = ''; for (const line of frame.split('\n')) { if (line.startsWith('event:')) event = line.slice(6).trim(); else if (line.startsWith('data:')) data += line.slice(5).trim(); } M.sseEvents++; if (event === 'resync') M.sseResyncs++; if (!data) return; let p; try { p = JSON.parse(data); } catch { return; } if (event === 'new-upload') M.sseNewUpload++; else if (event === 'upload-processed' && p?.upload_id) { if (!M.sseProcessed.has(p.upload_id)) M.sseProcessed.set(p.upload_id, now()); } else if (event === 'upload-error') M.sseErrors++; } close() { this.stop = true; try { this.ctrl?.abort(); } catch { /* noop */ } } } // ── Guest behaviours ───────────────────────────────────────────────────────── async function browseFeed(g, { pages = 1 } = {}) { let cursor = null; const seen = []; for (let p = 0; p < pages; p++) { const q = cursor ? `?limit=20&cursor=${cursor}` : '?limit=20'; const r = await http(`/feed${q}`, { token: g.jwt, ua: g.device.ua }); M.feed.push({ ms: r.ms, status: r.status }); if (r.status !== 200 || !r.body?.uploads?.length) break; seen.push(...r.body.uploads); cursor = r.body.next_cursor ?? r.body.cursor; if (!cursor) break; await think(frand(2, 8)); } return seen; } /** Opening photos is what a viewer actually does — and what costs the server. */ async function viewPhotos(g, uploads, count) { const chosen = shuffle([...uploads]).slice(0, count); for (const u of chosen) { await fetchMedia(u.id, g.device.mobile ? 'thumbnail' : 'preview', g.device.ua); if (Math.random() < 0.35) { await think(frand(1, 4)); await fetchMedia(u.id, 'display', g.device.ua); } if (Math.random() < g.likeRate) { const r = await http(`/upload/${u.id}/like`, { method: 'POST', token: g.jwt, ua: g.device.ua }); M.likes.push({ ms: r.ms, status: r.status }); } await think(frand(2, 10)); } } async function uploadBurst(g, files) { // Phones upload 2-3 at a time from the share sheet; desktops more. const conc = g.device.mobile ? rand(1, 3) : rand(2, 4); for (let i = 0; i < files.length; i += conc) { const chunk = files.slice(i, i + conc); const results = await Promise.all( chunk.map((f) => { const cap = pick(CAPTIONS); const tags = Math.random() < 0.6 ? `${pick(TAGS)},${pick(TAGS)}` : Math.random() < 0.5 ? pick(TAGS) : undefined; // The unsure persona sometimes gives up mid-upload and retries later. const abortAfterMs = g.persona === 'unsure' && Math.random() < 0.12 ? rand(300, 1500) : undefined; return uploadFile(g, f, { caption: cap, hashtags: tags, abortAfterMs }); }) ); for (const r of results) M.uploads.push(r); await think(frand(3, 15)); } } // ── Personas ───────────────────────────────────────────────────────────────── const PERSONAS = { photographer: { kind: 'uploader', likeRate: 0.05, join: [0.0, 0.08], dur: [0.85, 1.0], async loop(g) { // Works in sets: shoots, then dumps a big batch, then shoots again. while (g.queue.length && g.active()) { const batch = g.queue.splice(0, rand(12, 30)); await uploadBurst(g, batch); const feed = await browseFeed(g, { pages: 1 }); if (feed.length) await viewPhotos(g, feed, rand(1, 3)); // Shoots for another 40-100 real minutes before dumping the next card. await think(frand(2400, 6000)); } }, }, enthusiast: { kind: 'uploader', likeRate: 0.3, join: [0.0, 0.35], dur: [0.5, 0.95], async loop(g) { while (g.active()) { if (g.queue.length && Math.random() < 0.6) { await uploadBurst(g, g.queue.splice(0, rand(3, 10))); } const feed = await browseFeed(g, { pages: rand(1, 3) }); if (feed.length) await viewPhotos(g, feed, rand(3, 8)); await think(frand(200, 700)); } }, }, casual: { kind: 'uploader', likeRate: 0.45, join: [0.05, 0.8], dur: [0.06, 0.3], async loop(g) { // Joins, looks around, posts a couple of photos, keeps scrolling, leaves. const feed = await browseFeed(g, { pages: rand(1, 2) }); if (feed.length) await viewPhotos(g, feed, rand(2, 6)); if (g.queue.length) await uploadBurst(g, g.queue.splice(0, rand(1, 4))); while (g.active()) { const f2 = await browseFeed(g, { pages: 1 }); if (f2.length) await viewPhotos(g, f2, rand(2, 5)); await think(frand(120, 400)); } }, }, unsure: { kind: 'uploader', likeRate: 0.5, join: [0.1, 0.75], dur: [0.15, 0.6], async loop(g) { // Taps everything, gets confused, retries, tries features that are off. while (g.active()) { const act = Math.random(); if (act < 0.3 && g.queue.length) { await uploadBurst(g, g.queue.splice(0, rand(1, 2))); } else if (act < 0.45) { // Tries to comment. COMMENTS_ENABLED=false in this event → expect 403. const feed = await browseFeed(g, { pages: 1 }); if (feed.length) { const r = await http(`/upload/${pick(feed).id}/comments`, { method: 'POST', token: g.jwt, ua: g.device.ua, json: { body: 'Schön!' }, }); M.misc.push({ op: 'comment-while-disabled', ms: r.ms, status: r.status, code: r.code }); } } else if (act < 0.55) { // Double-taps like: toggles it straight back off. const feed = await browseFeed(g, { pages: 1 }); if (feed.length) { const id = pick(feed).id; for (let i = 0; i < 2; i++) { const r = await http(`/upload/${id}/like`, { method: 'POST', token: g.jwt, ua: g.device.ua }); M.likes.push({ ms: r.ms, status: r.status }); await sleep(rand(120, 400)); } } } else if (act < 0.65) { const r = await http('/hashtags', { token: g.jwt, ua: g.device.ua }); M.misc.push({ op: 'hashtags', ms: r.ms, status: r.status }); const r2 = await http('/uploaders', { token: g.jwt, ua: g.device.ua }); M.misc.push({ op: 'uploaders', ms: r2.ms, status: r2.status }); } else if (act < 0.72) { const r = await http('/me/quota', { token: g.jwt, ua: g.device.ua }); M.misc.push({ op: 'quota', ms: r.ms, status: r.status }); } else if (act < 0.78) { // Hunts for a keepsake download that isn't released yet. const r = await http('/export/status', { token: g.jwt, ua: g.device.ua }); M.misc.push({ op: 'export-status', ms: r.ms, status: r.status }); const r2 = await http('/export/ticket?kind=zip', { method: 'POST', token: g.jwt, ua: g.device.ua }); M.misc.push({ op: 'export-ticket-early', ms: r2.ms, status: r2.status, code: r2.code }); } else { const feed = await browseFeed(g, { pages: rand(1, 3) }); if (feed.length) await viewPhotos(g, feed, rand(1, 4)); } await think(frand(60, 300)); } }, }, flaky: { kind: 'uploader', likeRate: 0.25, join: [0.05, 0.7], dur: [0.3, 0.9], async loop(g) { // Bad signal: uploads abort part-way and are retried, feed calls fail. while (g.active()) { if (g.queue.length) { const batch = g.queue.splice(0, rand(2, 6)); for (const f of batch) { if (Math.random() < 0.3) { // Connection dies mid-upload. const r = await uploadFile(g, f, { caption: pick(CAPTIONS), abortAfterMs: rand(200, 2000), }); M.uploads.push(r); await think(frand(10, 40)); // …and the guest tries again. const r2 = await uploadFile(g, f, { caption: pick(CAPTIONS) }); M.uploads.push(r2); } else { M.uploads.push(await uploadFile(g, f, { caption: pick(CAPTIONS) })); } await think(frand(5, 25)); } } const feed = await browseFeed(g, { pages: 1 }); if (feed.length) await viewPhotos(g, feed, rand(1, 4)); await think(frand(100, 400)); } }, }, lurker: { kind: 'viewer', likeRate: 0.08, join: [0.0, 0.85], dur: [0.05, 0.7], async loop(g) { while (g.active()) { const feed = await browseFeed(g, { pages: rand(1, 3) }); if (feed.length) await viewPhotos(g, feed, rand(2, 7)); await think(frand(180, 900)); } }, }, social: { kind: 'viewer', likeRate: 0.7, join: [0.0, 0.7], dur: [0.2, 0.95], async loop(g) { while (g.active()) { const feed = await browseFeed(g, { pages: rand(2, 4) }); if (feed.length) await viewPhotos(g, feed, rand(5, 12)); // Filters by hashtag, like a guest hunting for the first-dance photos. if (Math.random() < 0.4) { const r = await http(`/feed?limit=20&hashtag=${pick(TAGS)}`, { token: g.jwt, ua: g.device.ua }); M.feed.push({ ms: r.ms, status: r.status }); } await think(frand(90, 400)); } }, }, kiosk: { kind: 'viewer', likeRate: 0, join: [0.0, 0.0], dur: [1.0, 1.0], async loop(g) { // The projector: never sleeps, always pulling the newest photos at display // size. This is the guest-visible "does the slideshow keep up" path. while (g.active()) { const r = await http('/feed?limit=20', { token: g.jwt, ua: g.device.ua }); M.feed.push({ ms: r.ms, status: r.status }); if (r.status === 200 && r.body?.uploads?.length) { for (const u of r.body.uploads.slice(0, 6)) { await fetchMedia(u.id, 'display', g.device.ua); await think(frand(6, 12)); } } await think(frand(10, 20)); } }, }, }; // ── Guest ──────────────────────────────────────────────────────────────────── class Guest { constructor(idx, persona, device) { this.idx = idx; this.persona = persona; this.spec = PERSONAS[persona]; this.device = device; this.likeRate = this.spec.likeRate; this.queue = []; this.jwt = null; this.pin = null; this.sse = null; this.joinAtMs = Math.floor(frand(...this.spec.join) * cfg.windowSec * 1000); const dur = frand(...this.spec.dur) * cfg.windowSec * 1000; this.leaveAtMs = Math.min(cfg.windowSec * 1000, this.joinAtMs + dur); } active() { return now() - T0 < this.leaveAtMs && !HALT; } async run() { await sleep(this.joinAtMs); if (HALT) return; const name = `${pick(FIRST)} ${pick(LAST)} ${this.idx}`; const r = await http('/join', { method: 'POST', json: { display_name: name }, ua: this.device.ua, }); M.joins.push({ ms: r.ms, status: r.status }); if (r.status !== 201) { M.misc.push({ op: 'join-failed', status: r.status, code: r.code }); return; } this.jwt = r.body.jwt; this.pin = r.body.pin; this.name = name; this.sse = new Sse(this).start(); // First thing every real guest does: look at the event and the feed. await http('/event', { ua: this.device.ua }); await http('/me/context', { token: this.jwt, ua: this.device.ua }); try { await this.spec.loop(this); } catch (e) { M.misc.push({ op: `persona-${this.persona}-crash`, note: String(e).slice(0, 160) }); } this.sse?.close(); // Some guests actually log out; most just close the tab. if (Math.random() < 0.2) await http('/session', { method: 'DELETE', token: this.jwt, ua: this.device.ua }); } } const FIRST = ['Anna','Ben','Clara','David','Emma','Felix','Greta','Hannes','Ida','Jonas','Katrin','Lukas','Marie','Noah','Olivia','Paul','Quirin','Rosa','Simon','Tessa','Ulrich','Vera','Wolf','Xenia','Yannick','Zoe']; const LAST = ['Müller','Schmidt','Schneider','Fischer','Weber','Meyer','Wagner','Becker','Hoffmann','Schäfer','Koch','Bauer','Richter','Klein','Wolf','Neumann']; // ── Abuse suite ────────────────────────────────────────────────────────────── // Every case declares what the server SHOULD do. A 5xx is always a failure; so is // a payload that gets accepted when it should have been refused. function jpegHeader(sizeBytes) { const b = Buffer.alloc(sizeBytes, 0x41); Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]).copy(b, 0); return b; } async function runAbuse(admin, poolFiles) { note('abuse suite starting'); const dev = pick(DEVICES); const j = await http('/join', { method: 'POST', json: { display_name: `Mallory ${rand(100, 999)}` }, ua: dev.ua }); if (j.status !== 201) { M.abuse.push({ name: 'abuse-join', status: j.status, pass: false, note: 'could not join' }); return; } const g = { idx: -1, persona: 'abuser', jwt: j.body.jwt, device: dev }; const attackerId = j.body.user_id; const smallReal = poolFiles.find((f) => f.bytes < 3e6 && f.magic === 'image/jpeg') ?? poolFiles[0]; const realBuf = await readFile(join(cfg.poolDir, smallReal.name)); const rec = (name, r, expectStatuses, note_) => { const status = r.status; const pass = expectStatuses.includes(status); M.abuse.push({ name, status, code: r.code, expected: expectStatuses.join('/'), pass, is5xx: status >= 500, note: note_ ?? (r.msg ?? (typeof r.body === 'object' ? r.body?.message : undefined)), }); return r; }; // ── Malicious file payloads ──────────────────────────────────────────────── rec( 'html-disguised-as-jpg', await uploadBuffer(g, Buffer.from(''), { mime: 'image/jpeg', filename: 'photo.jpg', }), [400], 'polyglot/HTML upload must be refused by magic-byte sniff' ); rec( 'svg-with-script', await uploadBuffer( g, Buffer.from(''), { mime: 'image/svg+xml', filename: 'x.svg' } ), [400] ); rec( 'php-webshell-as-jpg', await uploadBuffer(g, Buffer.from(''), { mime: 'image/jpeg', filename: 'shell.php.jpg', }), [400] ); rec( 'elf-binary-as-jpg', await uploadBuffer(g, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0, 0, 0]), { mime: 'image/jpeg', filename: 'a.jpg', }), [400] ); rec( 'zero-byte-file', await uploadBuffer(g, Buffer.alloc(0), { mime: 'image/jpeg', filename: 'empty.jpg' }), [400] ); rec( 'jpeg-magic-but-garbage-body', await uploadBuffer(g, jpegHeader(2 * 1024 * 1024), { mime: 'image/jpeg', filename: 'fake.jpg' }), [201, 400], 'valid JPEG magic, undecodable body. 201 is acceptable only if the worker then ' + 'marks it compression_status=error and emits upload-error — checked against the DB below' ); rec( 'oversize-image-30mb', await uploadBuffer(g, jpegHeader(30 * 1024 * 1024), { mime: 'image/jpeg', filename: 'huge.jpg' }), [400], 'over max_image_size_mb=20' ); rec( 'declared-video-to-smuggle-60mb-image', await uploadBuffer(g, jpegHeader(60 * 1024 * 1024), { mime: 'video/quicktime', filename: 'clip.mov', }), [400], 'declaring video/quicktime buys the 500 MB streaming cap; magic bytes are JPEG, so ' + 'the 20 MB image cap must still apply to the stored object' ); // ── Injection / malformed text on an otherwise valid image ───────────────── rec( 'xss-caption', await uploadBuffer(g, realBuf, { mime: 'image/jpeg', caption: '', filename: 'x.jpg', }), [201], 'server stores raw by design; escaping is the frontend contract' ); rec( 'nul-byte-caption', await uploadBuffer(g, realBuf, { mime: 'image/jpeg', caption: `Schoen\u0000 boom`, filename: 'x.jpg', }), [201, 400], 'a NUL in TEXT makes Postgres reject the INSERT — 500 here would be a real bug' ); rec( 'sql-injection-caption', await uploadBuffer(g, realBuf, { mime: 'image/jpeg', caption: `'); DROP TABLE upload; --`, filename: 'x.jpg', }), [201] ); rec( 'caption-over-2000-chars', await uploadBuffer(g, realBuf, { mime: 'image/jpeg', caption: 'ä'.repeat(2500), filename: 'x.jpg' }), [400] ); rec( 'hashtag-flood-60-tags', await uploadBuffer(g, realBuf, { mime: 'image/jpeg', hashtags: Array.from({ length: 60 }, (_, i) => `tag${i}`).join(','), filename: 'x.jpg', }), [201, 400] ); rec( 'path-traversal-filename', await uploadBuffer(g, realBuf, { mime: 'image/jpeg', filename: '../../../../etc/passwd.jpg', caption: 'traversal', }), [201], 'filename must not influence the stored path' ); rec( 'rtl-override-caption', await uploadBuffer(g, realBuf, { mime: 'image/jpeg', caption: 'photo‮gnp.exe', filename: 'x.jpg' }), [201] ); // ── Auth / authorisation ─────────────────────────────────────────────────── const parts = g.jwt.split('.'); const tamperedPayload = Buffer.from( JSON.stringify({ ...JSON.parse(Buffer.from(parts[1], 'base64url').toString()), role: 'admin' }) ).toString('base64url'); rec( 'jwt-role-escalation', await http('/admin/stats', { token: `${parts[0]}.${tamperedPayload}.${parts[2]}`, ua: dev.ua }), [401, 403] ); rec('jwt-alg-none', await http('/admin/stats', { token: `${parts[0]}.${parts[1]}.`, ua: dev.ua }), [401, 403]); rec('garbage-jwt', await http('/feed', { token: 'not.a.jwt', ua: dev.ua }), [401]); rec('guest-hits-admin-stats', await http('/admin/stats', { token: g.jwt, ua: dev.ua }), [401, 403]); rec( 'guest-hits-admin-config-patch', await http('/admin/config', { method: 'PATCH', token: g.jwt, ua: dev.ua, json: { quota_enabled: 'false' } }), [401, 403] ); rec( 'guest-hits-truncate', await http('/admin/__truncate', { method: 'POST', token: g.jwt, ua: dev.ua }), [401, 403], 'CRITICAL: a guest must never be able to wipe the event' ); rec('guest-hits-host-users', await http('/host/users', { token: g.jwt, ua: dev.ua }), [401, 403]); rec( 'guest-bans-another-user', await http(`/host/users/${randomUUID()}/ban`, { method: 'POST', token: g.jwt, ua: dev.ua }), [401, 403] ); // ── Cross-user tampering ─────────────────────────────────────────────────── // Must be SOMEONE ELSE's upload. The abuse suite has posted several of its own // by now, and they sort to the top of the feed — deleting one of those would be // a 204 that looks like a broken authorisation check but is simply correct. const victimFeed = await http('/feed?limit=100', { token: g.jwt, ua: dev.ua }); const victim = victimFeed.body?.uploads?.find((u) => u.id && u.user_id && u.user_id !== attackerId); if (!victim) M.abuse.push({ name: 'cross-user-tampering', status: 0, expected: 'another guest to exist', pass: false, note: 'no upload from a different user was visible yet — cases skipped', }); if (victim) { rec( 'delete-another-users-upload', await http(`/upload/${victim.id}`, { method: 'DELETE', token: g.jwt, ua: dev.ua }), [403, 404] ); rec( 'edit-another-users-caption', await http(`/upload/${victim.id}`, { method: 'PATCH', token: g.jwt, ua: dev.ua, json: { caption: 'defaced' }, }), [403, 404] ); } // ── Enumeration & malformed requests ─────────────────────────────────────── let enumHits = 0; for (let i = 0; i < 15; i++) { const r = await fetchMedia(randomUUID(), 'original', dev.ua); if (r.status === 200) enumHits++; } M.abuse.push({ name: 'media-uuid-enumeration', status: enumHits === 0 ? 404 : 200, expected: '404', pass: enumHits === 0, note: `${enumHits}/15 random UUIDs resolved`, }); rec('malformed-json-body', await http('/join', { method: 'POST', json: '{"display_name":', raw: true, ua: dev.ua }), [400, 422]); rec('non-uuid-path-segment', await http('/upload/not-a-uuid/like', { method: 'POST', token: g.jwt, ua: dev.ua }), [400, 404, 422]); rec('feed-delta-missing-since', await http('/feed/delta', { token: g.jwt, ua: dev.ua }), [400, 422]); rec('feed-negative-limit', await http('/feed?limit=-5', { token: g.jwt, ua: dev.ua }), [200, 400, 422]); rec('feed-huge-limit', await http('/feed?limit=999999', { token: g.jwt, ua: dev.ua }), [200, 400, 422]); rec('reserved-display-name', await http('/join', { method: 'POST', json: { display_name: 'admin' }, ua: dev.ua }), [409]); rec('control-chars-display-name', await http('/join', { method: 'POST', json: { display_name: 'evil\u0000name' }, ua: dev.ua }), [400]); rec('overlong-display-name', await http('/join', { method: 'POST', json: { display_name: 'A'.repeat(500) }, ua: dev.ua }), [400]); // ── Rate-limit probes ────────────────────────────────────────────────────── if (victim) { let limited = 0; for (let i = 0; i < 160; i++) { const r = await http(`/upload/${victim.id}/like`, { method: 'POST', token: g.jwt, ua: dev.ua }); if (r.status === 429) limited++; if (r.status >= 500) break; } M.abuse.push({ name: 'like-spam-160x (social_rate_per_min=120)', status: limited > 0 ? 429 : 200, expected: '429', pass: limited > 0, note: `${limited} of 160 refused`, }); } let loginLimited = 0; for (let i = 0; i < 12; i++) { const r = await http('/admin/login', { method: 'POST', json: { password: `wrong-${i}` }, ua: dev.ua }); if (r.status === 429) loginLimited++; } M.abuse.push({ name: 'admin-password-bruteforce-12x', status: loginLimited > 0 ? 429 : 401, expected: '429', pass: loginLimited > 0, note: `${loginLimited} of 12 refused`, }); // ── Quota-divisor abuse: throwaway accounts to shrink everyone's ceiling ─── const quotaBefore = await http('/me/quota', { token: g.jwt, ua: dev.ua }); let sybils = 0; let sybilLimited = 0; for (let i = 0; i < 60; i++) { const r = await http('/join', { method: 'POST', json: { display_name: `Sybil ${randomUUID().slice(0, 8)}` }, ua: dev.ua }); if (r.status === 201) sybils++; else if (r.status === 429) sybilLimited++; } const quotaAfter = await http('/me/quota', { token: g.jwt, ua: dev.ua }); // The documented mitigation is that the quota divisor is max(active_uploaders, // estimated_guest_count) — accounts that never upload must not move it. So the // pass condition is "active_uploaders did not rise", not "limit_bytes held": // limit_bytes legitimately falls all evening as the disk fills. const upBefore = quotaBefore.body?.active_uploaders; const upAfter = quotaAfter.body?.active_uploaders; M.abuse.push({ name: 'sybil-join-flood-60x (quota-divisor abuse)', status: sybilLimited > 0 ? 429 : 201, expected: 'active_uploaders unchanged by non-uploading accounts', pass: upAfter !== undefined && upAfter <= upBefore + 1, note: `${sybils} throwaway accounts created, ${sybilLimited} throttled; ` + `active_uploaders ${upBefore} → ${upAfter}; ` + `limit_bytes ${quotaBefore.body?.limit_bytes} → ${quotaAfter.body?.limit_bytes}`, }); note(`abuse suite done (${M.abuse.length} cases)`); } // ── Resource sampling ──────────────────────────────────────────────────────── async function sample() { const out = { tSec: Math.round((now() - T0) / 1000) }; try { const { stdout } = await execFileAsync('docker', [ 'stats', '--no-stream', '--format', '{{.Name}};{{.CPUPerc}};{{.MemUsage}};{{.MemPerc}}', cfg.appContainer, cfg.dbContainer, cfg.feContainer, cfg.caddyContainer, ]); out.containers = {}; for (const line of stdout.trim().split('\n')) { const [name, cpu, mem, memp] = line.split(';'); out.containers[name.replace('eventsnap-sim-', '').replace('-1', '')] = { cpu: parseFloat(cpu), mem, memPct: parseFloat(memp), }; } } catch (e) { out.dockerErr = String(e).slice(0, 100); } // Disk comes from the app's OWN view (statvfs on MEDIA_PATH) rather than a // `df` in the container — the app image is minimal and has no df, and this is // the exact number the upload gate makes its decision on. if (ADMIN_JWT) { const s = await http('/admin/stats', { token: ADMIN_JWT }); if (s.status === 200 && s.body) { out.disk = { size: s.body.disk_total_bytes, used: s.body.disk_used_bytes, avail: s.body.disk_free_bytes, }; out.uploadCount = s.body.upload_count; out.userCount = s.body.user_count; } } try { const { stdout } = await psql( `select compression_status, count(*) from upload where deleted_at is null group by 1` ); out.compression = {}; for (const l of stdout.trim().split('\n')) { if (!l) continue; const [k, v] = l.split('|'); out.compression[k] = +v; } } catch (e) { out.compressionErr = String(e).slice(0, 100); } try { const { stdout } = await psql(`select count(*) from pg_stat_activity where datname='eventsnap_test'`); out.dbConns = parseInt(stdout.trim(), 10); } catch { /* ignore */ } M.resources.push(out); return out; } function psql(sql) { return execFileAsync('docker', [ 'exec', cfg.dbContainer, 'psql', '-U', 'eventsnap_test', '-d', 'eventsnap_test', '-tAc', sql, ]); } // ── Stats ──────────────────────────────────────────────────────────────────── function summarize(nums) { if (!nums.length) return null; const s = [...nums].sort((a, b) => a - b); const at = (p) => s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]; return { n: s.length, min: s[0], p50: at(50), p95: at(95), p99: at(99), max: s[s.length - 1], mean: Math.round(s.reduce((a, b) => a + b, 0) / s.length), }; } /** * Scale a persona mix to a target headcount, keeping the proportions and never * dropping a persona entirely (a smoke run still has to exercise every code path). * Largest-remainder, so the counts sum to exactly `target`. */ function scaleMix(mix, target) { // Fewer seats than personas: keep the biggest ones rather than inventing a // fractional guest. Only happens in smoke runs. if (target <= mix.length) { return [...mix] .sort((a, b) => b[1] - a[1]) .slice(0, Math.max(1, target)) .map(([p]) => [p, 1]); } const base = mix.reduce((a, [, n]) => a + n, 0); const exact = mix.map(([p, n]) => [p, (n / base) * target]); const out = exact.map(([p, v]) => [p, Math.max(1, Math.floor(v))]); const order = exact .map(([, v], i) => [i, v - Math.floor(v)]) .sort((a, b) => b[1] - a[1]) .map(([i]) => i); // Largest-remainder settle. Each pass must change something or we stop, so a // mix that cannot absorb the difference can never spin forever. let diff = target - out.reduce((a, [, n]) => a + n, 0); while (diff > 0) { for (const i of order) { if (diff <= 0) break; out[i][1]++; diff--; } } while (diff < 0) { let moved = false; for (let k = order.length - 1; k >= 0 && diff < 0; k--) { const i = order[k]; if (out[i][1] > 1) { out[i][1]--; diff++; moved = true; } } if (!moved) break; } return out; } function countBy(arr, fn) { const m = {}; for (const x of arr) { const k = fn(x); m[k] = (m[k] ?? 0) + 1; } return m; } // ── Main ───────────────────────────────────────────────────────────────────── let T0 = now(); let HALT = false; /** Set once at startup; lets the resource sampler read the app's own disk view. */ let ADMIN_JWT = null; async function main() { console.log('═'.repeat(78)); console.log('EventSnap EVENT SIMULATION — real content, 2 vCPU / 4 GB / 30 GB box'); console.log('═'.repeat(78)); const poolMeta = JSON.parse(await readFile(cfg.poolMeta, 'utf8')); const usable = poolMeta.filter((f) => f.magic !== 'unknown'); console.log( `[pool] ${poolMeta.length} real files, ${(poolMeta.reduce((a, f) => a + f.bytes, 0) / 1e9).toFixed(2)} GB ` + `(uploaded as-is — the app decides what it refuses)` ); const admin = await adminLogin(); if (cfg.truncate) { console.log('[setup] truncating event data…'); const t = await http('/admin/__truncate', { method: 'POST', token: admin }); if (t.status !== 204) throw new Error(`truncate failed ${t.status} ${JSON.stringify(t.body)}`); } const admin2 = await adminLogin(); ADMIN_JWT = admin2; const cfgNow = await http('/admin/config', { token: admin2 }); // The truncate handler reseeds config with every toggle OFF. Restore the SHIPPING // defaults, because running with rate limits and quotas off would test a system // nobody deploys — and would make the abuse suite meaningless. const shipping = { rate_limits_enabled: 'true', upload_rate_enabled: 'true', feed_rate_enabled: 'true', join_rate_enabled: 'true', recover_rate_enabled: 'true', social_rate_enabled: 'true', export_rate_enabled: 'true', admin_login_rate_enabled: 'true', upload_edit_rate_enabled: 'true', quota_enabled: 'true', storage_quota_enabled: 'true', upload_count_quota_enabled: 'true', }; const patch = await http('/admin/config', { method: 'PATCH', token: admin2, json: shipping }); console.log(`[setup] shipping config restored (rate limits + quotas ON): ${patch.status === 204 ? 'ok' : 'FAILED ' + patch.status}`); const cfgAfter = await http('/admin/config', { token: admin2 }); // ── Build the cast ───────────────────────────────────────────────────────── // 50 uploaders + 100 viewers. The mix is chosen so the request profile matches a // real event: a few people take most of the photos, most people mostly look. const uploaderMix = scaleMix( [ ['photographer', 4], ['enthusiast', 12], ['casual', 18], ['unsure', 8], ['flaky', 8], ], cfg.uploaders ); const viewerMix = scaleMix( [ ['lurker', 67], ['social', 30], ['kiosk', 3], ], cfg.viewers ); const guests = []; let idx = 0; for (const [p, n] of uploaderMix) for (let i = 0; i < n; i++) guests.push(new Guest(idx++, p, pick(DEVICES))); const uploaderCount = guests.length; for (const [p, n] of viewerMix) for (let i = 0; i < n; i++) guests.push(new Guest(idx++, p, p === 'kiosk' ? KIOSK_DEVICE : pick(DEVICES))); // ── Hand the real photos out ─────────────────────────────────────────────── // Weighted so the four photographers carry the bulk, exactly like a real wedding. // One photographer is deliberately given more than upload_rate_per_hour (100) to // find out what the shipping limiter does to a pro dumping a card. const files = shuffle([...usable]).slice(0, cfg.maxFiles > 0 ? cfg.maxFiles : usable.length); const uploaders = guests.filter((g) => g.spec.kind === 'uploader'); const weights = uploaders.map((g) => g.persona === 'photographer' ? 90 : g.persona === 'flaky' ? 22 : g.persona === 'enthusiast' ? 25 : g.persona === 'unsure' ? 6 : 4 ); weights[0] = 130; // the pro with the full card const total = weights.reduce((a, b) => a + b, 0); let cursor = 0; uploaders.forEach((g, i) => { const share = Math.round((weights[i] / total) * files.length); g.queue = files.slice(cursor, cursor + share); cursor += share; }); if (cursor < files.length) uploaders[0].queue.push(...files.slice(cursor)); console.log( `[cast] ${guests.length} sessions: ${uploaderCount} uploaders / ${guests.length - uploaderCount} viewers` ); console.log(` ${JSON.stringify(countBy(guests, (g) => g.persona))}`); console.log(` devices ${JSON.stringify(countBy(guests, (g) => g.device.name))}`); console.log( `[time] ${cfg.realEventHours} h event compressed into ${cfg.windowSec}s (${TIME_SCALE().toFixed(0)}x)` ); console.log('═'.repeat(78)); // ── Go ───────────────────────────────────────────────────────────────────── T0 = now(); const sampler = setInterval(sample, 5000); await sample(); const ticker = setInterval(() => { const ok = M.uploads.filter((u) => u.status === 201).length; const last = M.resources[M.resources.length - 1] ?? {}; const d = last.disk ? `disk ${(last.disk.used / 1e9).toFixed(1)}/${(last.disk.size / 1e9).toFixed(0)}GB` : ''; const c = last.containers?.app ? `app ${last.containers.app.cpu.toFixed(0)}%/${last.containers.app.memPct.toFixed(0)}%mem` : ''; console.log( `[t+${Math.round((now() - T0) / 1000)}s] up ${M.uploads.length} (ok ${ok}) · ` + `processed ${M.sseProcessed.size} · feed ${M.feed.length} · media ${M.media.length} · ` + `likes ${M.likes.length} · ${c} · ${d} · 5xx ${M.serverErrors.length}` ); }, 15000); const runs = guests.map((g) => g.run()); // The abuse personas start once there is real content to attack. const abuseRun = cfg.abuse ? sleep(cfg.windowSec * 250).then(() => runAbuse(admin2, usable).catch((e) => note(`abuse crash: ${e}`))) : Promise.resolve(); await Promise.all([...runs, abuseRun]); clearInterval(ticker); note('all guest sessions finished — uploads closed'); // ── Drain ────────────────────────────────────────────────────────────────── note('waiting for the compression backlog to drain (this is the diashow catching up)'); const drainStart = now(); let drainReason = 'timeout'; const okIds = new Set(M.uploads.filter((u) => u.id).map((u) => u.id)); let dbBlind = 0; while (now() - drainStart < cfg.drainTimeoutSec * 1000) { const s = await sample(); const pendingDb = Object.entries(s.compression ?? {}) .filter(([k]) => !TERMINAL_COMPRESSION.has(k)) .reduce((a, [, n]) => a + n, 0); const pendingSse = [...okIds].filter((id) => !M.sseProcessed.has(id)).length; if (s.compression && pendingDb === 0) { drainReason = 'db-complete'; note(`backlog cleared per DB: ${JSON.stringify(s.compression)}`); break; } // If the DB ground truth is unreadable, fall back to what the diashow saw // rather than spinning until the timeout on a broken query. if (!s.compression) { dbBlind++; if (dbBlind >= 3 && pendingSse === 0) { drainReason = 'sse-complete (db unreadable)'; note(`DB counts unavailable (${s.compressionErr ?? '?'}); every upload got an SSE upload-processed`); break; } } else { dbBlind = 0; } console.log( `[drain +${Math.round((now() - drainStart) / 1000)}s] pending(db) ${pendingDb} pending(sse) ${pendingSse} ` + `${JSON.stringify(s.compression ?? {})} app ${s.containers?.app?.cpu?.toFixed(0)}%` ); await sleep(5000); } const drainMs = now() - drainStart; clearInterval(sampler); HALT = true; guests.forEach((g) => g.sse?.close()); // ── Ground truth from the DB ─────────────────────────────────────────────── const truth = {}; for (const [k, sql] of Object.entries({ users: 'select count(*) from "user"', uploads: 'select count(*) from upload where deleted_at is null', byStatus: `select string_agg(s||'='||n, ' ') from (select compression_status s, count(*) n from upload where deleted_at is null group by 1 order by 1) t`, derivativeErrors: `select coalesce(string_agg(distinct left(derivative_last_error,60), ' | '),'none') from upload where derivative_last_error is not null`, likes: 'select count(*) from "like"', comments: 'select count(*) from comment', hashtags: 'select count(*) from hashtag', softDeleted: 'select count(*) from upload where deleted_at is not null', mediaBytes: 'select coalesce(sum(original_size_bytes),0) from upload where deleted_at is null', })) { try { truth[k] = (await psql(sql)).stdout.trim(); } catch (e) { truth[k] = `err: ${String(e).slice(0, 80)}`; } } const stats = await http('/admin/stats', { token: await adminLogin() }); const finalSample = await sample(); const pgSize = await execFileAsync('docker', ['system', 'df', '-v']) .then(({ stdout }) => stdout.split('\n').find((l) => l.includes('sim_pgdata'))?.trim()) .catch(() => null); // ── Report ───────────────────────────────────────────────────────────────── const okUploads = M.uploads.filter((u) => u.status === 201); const pipeline = [...M.sseProcessed.entries()] .filter(([id]) => uploadEndTs.has(id)) .map(([id, ts]) => ts - uploadEndTs.get(id)); const firstQuota = M.uploads.filter((u) => u.status === 413).sort((a, b) => a.endTs - b.endTs)[0]; const bytesBeforeQuota = firstQuota ? okUploads.filter((u) => u.endTs < firstQuota.endTs).reduce((a, u) => a + u.bytes, 0) : null; const report = { meta: { startedAt: new Date(T0).toISOString(), durationSec: Math.round((now() - T0) / 1000), windowSec: cfg.windowSec, timeCompression: `${TIME_SCALE().toFixed(0)}x (${cfg.realEventHours}h → ${cfg.windowSec}s)`, box: '2 vCPU (cpuset 0,1) / 4 GB / 30 GB media volume', images: { app: 'registry.mc02.dev/eventsnap/app:v0.17.5', frontend: 'registry.mc02.dev/eventsnap/frontend:v0.17.6' }, rateLimits: 'SHIPPING DEFAULTS — on', }, cast: { sessions: guests.length, uploaders: uploaderCount, viewers: guests.length - uploaderCount, personas: countBy(guests, (g) => g.persona), devices: countBy(guests, (g) => g.device.name), }, uploads: { attempted: M.uploads.length, ok: okUploads.length, byStatus: countBy(M.uploads, (u) => u.status), byErrorCode: countBy(M.uploads.filter((u) => u.status >= 400), (u) => `${u.status} ${u.code ?? '?'}`), rejectionMessages: countBy( M.uploads.filter((u) => u.status >= 400 && u.msg), (u) => u.msg ), byPersona: countBy(M.uploads, (u) => `${u.persona}:${u.status}`), okBytes: okUploads.reduce((a, u) => a + u.bytes, 0), latencyMs: summarize(okUploads.map((u) => u.ms)), }, pipeline: { processedEvents: M.sseProcessed.size, latencyMs: summarize(pipeline), drainMs, drainCleared: drainReason !== 'timeout', drainReason, }, diskGate: { firstQuotaRejectAtSec: firstQuota ? Math.round((firstQuota.endTs - T0) / 1000) : null, uploadsAcceptedBeforeGate: firstQuota ? okUploads.filter((u) => u.endTs < firstQuota.endTs).length : null, bytesAcceptedBeforeGate: bytesBeforeQuota, totalQuotaRejections: M.uploads.filter((u) => u.status === 413).length, finalDisk: finalSample.disk, pgVolume: pgSize, }, viewers: { feedRequests: M.feed.length, feedLatencyMs: summarize(M.feed.filter((f) => f.status === 200).map((f) => f.ms)), feedByStatus: countBy(M.feed, (f) => f.status), mediaRequests: M.media.length, mediaLatencyMs: summarize(M.media.filter((m) => m.status === 200 || m.status === 206).map((m) => m.ms)), mediaByStatus: countBy(M.media, (m) => m.status), likeRequests: M.likes.length, likeByStatus: countBy(M.likes, (l) => l.status), joinLatencyMs: summarize(M.joins.filter((j) => j.status === 201).map((j) => j.ms)), joinByStatus: countBy(M.joins, (j) => j.status), miscByOp: countBy(M.misc, (m) => `${m.op}:${m.status ?? '-'}`), }, sse: { totalEvents: M.sseEvents, newUpload: M.sseNewUpload, processed: M.sseProcessed.size, uploadErrors: M.sseErrors, reconnects: M.sseReconnects, resyncs: M.sseResyncs, }, abuse: M.abuse, serverErrors: M.serverErrors, dbTruth: truth, adminStats: stats.body, configAfterSetup: cfgAfter.body, configAtTruncate: cfgNow.body, resources: M.resources, timeline: M.timeline, }; await mkdir(cfg.outDir, { recursive: true }); const stamp = new Date(T0).toISOString().replace(/[:.]/g, '-'); const out = join(cfg.outDir, `sim-${stamp}.json`); await writeFile(out, JSON.stringify(report, null, 2)); // ── Verdict ──────────────────────────────────────────────────────────────── const L = (s) => console.log(s); L('\n' + '═'.repeat(78)); L('RESULTS'); L('═'.repeat(78)); L(`duration ${report.meta.durationSec}s (${report.meta.timeCompression})`); L(`sessions ${report.cast.sessions} (${report.cast.uploaders} uploaders / ${report.cast.viewers} viewers)`); L(`uploads ${report.uploads.ok}/${report.uploads.attempted} accepted, ${(report.uploads.okBytes / 1e9).toFixed(2)} GB`); L(` by status ${JSON.stringify(report.uploads.byStatus)}`); L(` by error code ${JSON.stringify(report.uploads.byErrorCode)}`); L(`upload latency ${JSON.stringify(report.uploads.latencyMs)}`); L(`pipeline latency ${JSON.stringify(report.pipeline.latencyMs)}`); L(`backlog drain ${(drainMs / 1000).toFixed(0)}s cleared=${report.pipeline.drainCleared} (${drainReason})`); L(`feed latency ${JSON.stringify(report.viewers.feedLatencyMs)}`); L(`media latency ${JSON.stringify(report.viewers.mediaLatencyMs)}`); L(`sse ${M.sseEvents} events, ${M.sseReconnects} reconnects, ${M.sseResyncs} resyncs`); L(`db truth ${JSON.stringify(truth)}`); L(`disk ${finalSample.disk ? `${(finalSample.disk.used / 1e9).toFixed(2)} GB used of ${(finalSample.disk.size / 1e9).toFixed(1)} GB` : 'n/a'}`); if (firstQuota) L( `disk gate closed after ${report.diskGate.uploadsAcceptedBeforeGate} uploads / ` + `${(bytesBeforeQuota / 1e9).toFixed(2)} GB at t+${report.diskGate.firstQuotaRejectAtSec}s ` + `(${report.diskGate.totalQuotaRejections} rejections total)` ); const abuseFail = M.abuse.filter((a) => !a.pass); L(`\nabuse suite ${M.abuse.length - abuseFail.length}/${M.abuse.length} behaved as specified`); for (const a of abuseFail) L(` ✗ ${a.name}: got ${a.status} (${a.code ?? '-'}), expected ${a.expected} — ${a.note ?? ''}`); L('\nflags:'); const flags = []; if (M.serverErrors.length) flags.push(`✗ ${M.serverErrors.length} server errors (5xx) — see report.serverErrors`); if (!report.pipeline.drainCleared) flags.push(`✗ compression backlog never drained in ${cfg.drainTimeoutSec}s`); if (abuseFail.some((a) => a.is5xx)) flags.push(`✗ abuse input caused a 5xx`); if (abuseFail.length) flags.push(`⚠ ${abuseFail.length} abuse cases deviated from spec`); if (report.pipeline.latencyMs?.p95 > 60000) flags.push(`⚠ pipeline p95 ${(report.pipeline.latencyMs.p95 / 1000).toFixed(0)}s — photos lag the diashow`); if (report.viewers.feedLatencyMs?.p95 > 1000) flags.push(`⚠ feed p95 ${report.viewers.feedLatencyMs.p95}ms — the app feels slow to guests`); if (report.diskGate.totalQuotaRejections) flags.push(`⚠ ${report.diskGate.totalQuotaRejections} uploads refused for disk (413 quota_exceeded)`); if (M.uploads.filter((u) => u.status === 429).length) flags.push(`⚠ ${M.uploads.filter((u) => u.status === 429).length} uploads rate-limited (429)`); if (flags.length) flags.forEach((f) => L(' ' + f)); else L(' ✓ clean run'); L(`\nfull report → ${out}`); L('═'.repeat(78)); } main().catch((e) => { console.error('\n✗ simulation failed:', e); process.exit(1); });