#!/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: 'photognp.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);
});