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