Files
EventSnap/e2e/loadtest/acceptance-audit.mjs
MechaCat02 0a33189e7e
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m9s
Audit / npm audit (frontend) (push) Successful in 41s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m11s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Has been cancelled
Checks / E2E — typecheck + lint (push) Has been cancelled
E2E / Cross-UA smoke matrix (push) Has been cancelled
E2E / Playwright E2E (chromium + webkit) (push) Has been cancelled
Checks / Frontend — vitest + svelte-check (push) Has been cancelled
test(loadtest): audit which real files the upload validator actually refuses
"Will my photos be accepted?" was being answered by reasoning about the size
caps, which is the wrong method — size is only one of the paths that refuses a
file. The magic-byte allowlist, the decode budget (12000 px axis / 256 MiB
alloc, both code constants no setting can relax), the disk gate and the per-user
quota all reject too, and only the running server knows how they interact.

`acceptance-audit.mjs` pushes every file in the pool through the real endpoint
and groups the refusals by the server's own German message. Against the 945-file
wedding set with the raised limits it answered the question exactly: 935
accepted, and the only 10 refusals are the HEIC files.

Deliberately not a load test — no personas, no viewers, no think-time. It
measures admission, so it does not wait for the compression backlog to drain.

The sim stack gains two overridable vars, so the harness can be pointed at the
configuration actually deployed rather than the defaults: SIM_APP_IMAGE /
SIM_FE_IMAGE (audit a release image, not a local build) and KEEPSAKE_ENABLED,
which changes the disk gate and therefore changes what gets refused.

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

179 lines
6.5 KiB
JavaScript
Raw Blame History

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