Files
EventSnap/e2e/loadtest/event-sim.mjs
MechaCat02 e3159299c0
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m2s
Audit / npm audit (frontend) (push) Successful in 51s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 52s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m41s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m5s
Checks / E2E — typecheck + lint (push) Failing after 39s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 9m13s
E2E / Cross-UA smoke matrix (push) Failing after 4m20s
test(loadtest): an event simulation on a real 2 vCPU / 4 GB / 30 GB box
`driver.mjs` is a pipeline benchmark: synthetic images, uniform load, rate limits
off, and — the part that mattered — an unconstrained host, so the 1 GB app cap
was never exercised and the disk gate never fired. It could not have found
either of the two defects fixed in the preceding commits.

This harness differs in three ways that earn their keep:

REAL CONTENT. Uploads come from a pool of actual wedding photos and videos,
unedited, including the HEIC files and 25 MB frames the app is supposed to
REFUSE. Those refusals are the test, not noise to filter out — 152 of 932
attempts were refused, and the breakdown of WHY is the most actionable output.

PERSONAS. ~100 viewers and ~50 uploaders across nine behaviour profiles, six
device profiles, each with a join time and a session length. A casual guest who
posts four photos generates a completely different request mix than a
photographer dumping 130, and both differ from a kiosk holding one SSE stream all
night. A 37-case abuse suite covers malicious payloads, injection, cross-user
tampering, enumeration and the rate limiters.

RATE LIMITS STAY ON. `driver.mjs` disabled them because it ran every guest from
one IP. Almost every limit that matters is per USER, not per IP, and those are 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 ARE distorted by
the single source address; that distortion is measured and reported rather than
configured away.

`docker-compose.sim.yml` reproduces the CX22 rather than asserting it: production's
per-service cpus/memory/cpu_shares verbatim, every container pinned to the SAME two
cores with `cpuset` so they genuinely contend, and a real loopback ext4 volume so
the app's own statvfs returns true numbers. Run the driver under `taskset` onto
other cores, or the load generator competes with the thing it measures.

`browser-check.mjs` covers what an HTTP driver structurally cannot: the SvelteKit
container, and whether the frontend ESCAPES the XSS caption the backend stores
verbatim. The backend stores captions raw by design, so the renderer is the entire
defence — and only a browser can prove the payload is inert. It reports
INCONCLUSIVE rather than PASS when the payload never reached the DOM, because a
check that renders nothing proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:58:17 +02:00

1557 lines
60 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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('<html><script>alert(1)</script></html>'), {
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('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'),
{ mime: 'image/svg+xml', filename: 'x.svg' }
),
[400]
);
rec(
'php-webshell-as-jpg',
await uploadBuffer(g, Buffer.from('<?php system($_GET["c"]); ?>'), {
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: '<script>alert(document.cookie)</script><img src=x onerror=alert(1)>',
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);
});