fix(audit-2): video playback, role identity, keepsake EXIF, recover ceiling, WebKit CI
Second round: the two regressions the first round introduced, the remaining
gaps it left, and the CI change that makes the iOS guarantees actually gate a
change rather than being asserted.
Squashed from 9 commits, original messages preserved below.
──────── docs(deploy): document the update path — `up -d` alone ships nothing
The README only ever described a fresh install. There was no update section
anywhere, and `--build` appeared nowhere in the docs.
That matters because `app` and `frontend` are `build:` services with no published
image tag, and Compose has no source-change detection: if an image by that name
exists it is reused. So the natural `git pull && docker compose up -d` reports
"Container app-1 Running", rebuilds nothing, and exits 0. A deploy that shipped
none of the new code is indistinguishable from a successful one — which is how
eleven merged fixes can sit in the repo and never reach the box.
Verified both halves against a real stack rather than asserting them: with a
source change staged, `up -d` left the image ID untouched; `up -d --build`
produced a new image ID and a healthy /health.
Adds an "Updating an existing deployment" section covering backup-before-migrate,
pull, rebuild, health check, and an image-ID comparison to prove a build actually
happened. Also spells out the rollback trap: migrations run on boot and are not
undone by checking out an older commit, so rolling back code without restoring
the snapshot leaves the schema ahead of the binary and the app refusing to start.
──────── fix(video): play the actual video, and answer Range requests
Every video in the app was unplayable. Two independent defects, either one
sufficient on its own, and nothing in the suite covered either — no test
anywhere played media or asserted a `<video>` src.
1. The lightbox handed `<video>` a JPEG.
`pickMediaUrl` is mime-agnostic, and compression only ever produces a THUMBNAIL
for a video (one `ffmpeg -vframes 1` frame) — no preview, no display. So in the
DEFAULT saver mode the element's src resolved to `/api/v1/upload/{id}/thumbnail`,
served as `image/jpeg` with `nosniff` so the browser can't even sniff its way
out. Chromium reports DEMUXER_ERROR_COULD_NOT_OPEN.
Fixed in the lightbox rather than in `pickMediaUrl`: FeedListCard shares that
helper and legitimately wants the thumbnail for its `<img>` poster, so a central
mime branch would break the feed. This mirrors the rule the diashow already
applies ("videos play the original file directly"). Added `preload="none"` so
saver-mode guests on cellular still fetch nothing until they press play — there
is no smaller video derivative to offer them — plus `playsinline`, without which
iOS hijacks playback into fullscreen.
2. `stream_media_file` ignored Range entirely.
It took no request headers, so it could not see `Range`; it always returned 200
with the whole body and never sent Accept-Ranges or Content-Range. iOS Safari
opens every `<video>` with a `Range: bytes=0-1` probe and abandons the load
without a 206 — so video failed on the app's primary platform even in `original`
mode, where the src was already correct.
Adds single-range support (`bytes=N-`, `bytes=N-M`, `bytes=-S`) with 206 +
Content-Range, 416 + `bytes */len` past EOF, and Accept-Ranges advertised on
every response. Anything it won't handle — multi-range, non-bytes units, garbage
— falls back to a full 200, which RFC 9110 explicitly permits and which is safer
than guessing. All four media routes share the helper, so seeking works
uniformly.
`get_original` now serves `inline` instead of `attachment`. An attachment
disposition is hostile to a `<video>` element, and this route is the only source
of playable video bytes; it also matches what the UI promises, since the action
is labelled "Original anzeigen" — view, not download. `no-store` is deliberately
kept so a takedown still revokes access promptly; ranges work fine under it, the
client just re-fetches.
Tests: 11 unit tests pin the parser (the iOS `bytes=0-1` probe, inclusive ends,
suffix ranges, clamping past EOF, 416 vs 200, malformed fallbacks). A new
03-feed/video-playback spec asserts the src is the original and not the
thumbnail, that the browser accepts the bytes as media (readyState > 0, no
MediaError), that no video bytes are delivered before play, and that Range
returns the correct 206 slices and a 416 past EOF — verified on both Chromium
and WebKit.
The "not downloaded before play" test asserts no *delivered body* rather than no
request: WebKit opens a connection for a preload="none" video and immediately
aborts it (GET, no Range, status 0, nothing transferred) while Chromium issues
nothing at all. The portable guarantee is that no response carrying bytes
completes.
──────── fix(auth): bind the role store to the identity, not to the tab
The role store I added in the moderation work is a module-level singleton seeded
ONCE at import. `goto()` is a client-side navigation, so leaving and re-joining in
the same tab re-imports no module and re-runs no onMount — the previous user's
role simply stayed resident. Nothing reset it: not join, recover, admin login,
"Event verlassen", `clearAuth`, nor the api.ts 401 auto-clear.
So a host who left, followed by a guest joining on the same phone, left that guest
with `isStaff === true` and a "🚫 Beitrag entfernen" action on other people's
photos. The backend 403s the delete, so this was a false affordance rather than a
privilege escalation — but `/feed` never fetched `/me/context`, so unlike every
other route it never self-corrected either. It survived until a hard reload.
The mirror case was equally broken and easier to overlook: a guest who recovered
into a host account got NO host affordances.
`clearAuth` already had a hook registry for exactly this shape of problem, with a
comment explaining it exists to avoid circular imports. Add the missing mirror,
`onSetAuth`, fired by both `setAuth` and `setAdminAuth` after the new token is
resident, and have the role store register on both sides: clear to null on
logout, re-seed from the new token on login. That also gives
`syncRoleFromToken` — dead code with zero callers since I introduced it — its
intended purpose.
Seeding from the claim fixes the reported bug, but the claim is frozen for the
token's 30-day life, so a promotion or demotion still wouldn't reach the feed.
`/feed` now calls the existing `refreshEventState()` on mount, which fetches
`/me/context` and applies both the authoritative role and the lock/release state
in one request. The feed is the one route gating a destructive action on the role,
so it should not be the only route running on a stale claim.
Tests: 04-host/role-identity-reset drives the real flows. The first asserts the
host DOES see the action before asserting the newcomer does not — a negative
assertion alone would pass against a build that shipped no moderation at all. The
second covers the mirror, promoting a guest server-side while their resident token
still claims `role: guest`, so a fix that only cleared the role would fail it.
──────── fix(compression): reclaim failed originals instead of leaking them
Round 1 stopped the compression worker deleting an upload's original on failure —
a transient ENOSPC or a codec panic must never destroy the only copy of a photo a
guest cannot retake. But it left `Upload::soft_delete`'s quota refund in place, so
the bytes stayed on disk while the uploader was charged nothing for them.
That is worse than it first looks. The row is soft-deleted, so the file is
invisible and unowned; a guest hitting a reproducible codec failure can accumulate
orphans indefinitely at zero personal cost. And `active_uploaders` counts only
users with non-deleted uploads, so dropping out of that count RAISES everyone's
per-user ceiling — the leak loosens the very quota meant to contain it.
Keep the refund: the uploader didn't cause the failure and shouldn't silently lose
quota to it. Bound the leak instead, with an hourly sweep alongside the existing
session cleanup in `spawn_periodic_tasks`, reclaiming failed originals older than
14 days — comfortably longer than any single event, so an operator investigating a
failed upload still has the file.
The selection predicate is the entire safety argument, so it is deliberately
narrow: `compression_status = 'failed'` AND soft-deleted AND past the window AND
`original_path <> ''`. That is exactly the state the give-up path leaves behind,
and it cannot reach a live upload, an owner-deleted one, or a failure still inside
its recovery window. `original_path` is cleared after a successful reclaim, which
makes the sweep idempotent — otherwise a row whose file is already gone is
re-selected on every tick forever. The row itself is kept as the audit trail.
Tests reproduce the selection verbatim (same pattern as upload_concurrency) and
assert it against five near-misses that must survive, both sides of the retention
boundary, and the idempotence property.
Also fixes two comments in export.rs still claiming "the compression worker
hard-deletes an original when its transcode fails" — no longer true, and the
defensive handling they justify is now justified by this sweep and by ordinary
deletes instead.
──────── fix(export): apply EXIF orientation in the keepsake too
Round 1 fixed EXIF orientation in the compression worker, which corrected the live
app — feed preview and diashow display. The export worker was missed, and it does
not reuse those derivatives: it re-decodes the originals itself with `image::open`,
which ignores the orientation tag, then re-encodes to JPEG, which drops the tag —
so the viewer has no way to recover it.
The damage was oddly shaped, which is exactly why it reads as a viewer bug:
Gallery.zip originals correct (byte-copied, EXIF intact)
Memories viewer grid thumbnails SIDEWAYS (always)
Memories viewer full image >5 MB SIDEWAYS (re-encoded at 2000px)
Memories viewer full image ≤5 MB correct (streamed byte-for-byte)
So in the keepsake people actually keep, every portrait photo in the grid was on
its side, and clicking through silently "fixed" small photos but not large ones.
Rather than paste the decoder dance a third time, extract `services::imaging::
decode_oriented` and route both workers through it, so there is exactly one way to
turn a file on disk into a DynamicImage. It carries a second invariant that had
also drifted: `image::open` applies NO decode limits, so the export path was
decoding arbitrary user-supplied images unbounded — the decompression-bomb cap
existed only in the compression worker. Both now come as a pair, which is the
point of having one function.
Not done: switching export to consume the existing `display` derivative. It would
fix orientation and drop a redundant full-resolution decode per photo, but it
would also replace the pristine ≤5 MB originals in the keepsake with 2048px
re-encodes — a real quality regression in the one artefact people keep forever.
Test uploads the round-1 fixture (40x20 landscape tagged Orientation=6), runs a
real export, pulls the thumbnail out of Memories.zip and asserts it came back
portrait — with a sanity check that the source really is stored landscape, so the
test can't pass against a pipeline that does nothing.
──────── fix(recover): cap name cycling, and stop bcrypt blocking the runtime
Round 1 gave /join a per-IP ceiling and left /recover with only its
`recover:{ip}:{name}` bucket. That key is right for the job it was written for —
stopping someone who knows a display name (they're listed on the feed) from
burning the victim's 3-strike PIN counter and locking them out on repeat. But the
name is ATTACKER-CHOSEN, so cycling names mints a fresh 5-attempt bucket every
time and the per-IP cost is unbounded.
What sits behind that limiter makes it worse than a normal flood: every call runs
a cost-12 bcrypt verify, including an UNCONDITIONAL throwaway verify for names
that don't exist — added deliberately to close a timing oracle. So an unknown name
is the single cheapest way to make the server do ~200ms of hashing.
Adds `recover_ip_rate_per_min` (default 30, migration 019), checked BEFORE the
per-name bucket so a name generator can't walk past it. 30/min is far above any
real recovery attempt while capping a flood. The per-name bucket is untouched and
remains the anti-guessing control.
The second half matters as much as the first: bcrypt was running inline on the
async runtime everywhere. At cost 12 that pins a tokio worker thread for ~200ms,
and there is only one per core — so a login flood stalled every other request on
the box, including the feed. There was no spawn_blocking anywhere in the auth
module, despite SECURITY-BACKLOG claiming bcrypt had been offloaded.
Route all of it through `verify_password` / `hash_password` on the blocking pool.
That covers /recover, /admin/login, the host PIN reset, and — the one most likely
to bite at a real event — the PIN hash minted on every single /join. Saturating
the blocking pool degrades logins; saturating the worker threads degrades
everything.
Tests: cycling distinct names from one IP now hits the ceiling with a Retry-After,
and — the assertion that keeps the fix honest — repeated wrong PINs against ONE
name are still throttled with the ceiling set generously high, so the ceiling
added protection rather than replacing it.
──────── ci(e2e): run WebKit, so the iOS guarantees actually gate a PR
The workflow installed only Chromium and ran chromium-desktop + chromium-mobile.
iOS Safari is the app's stated primary user — a wedding guest opening a QR link —
and WebKit is the only engine in the matrix that reproduces two of its behaviours:
- it enforces X-Frame-Options on the hidden download iframe, so a site-wide DENY
makes the keepsake download silently do nothing. Blink hands attachments to
the download manager before the frame check and never notices.
- it abandons a <video> load unless its Range probe gets a 206.
Both of those shipped. Adding 06-export to the webkit project in the round-1 fix
bought nothing on a PR, because CI never ran that project at all — the regression
test written specifically to catch the blocker only ever executed locally.
Runs 71 tests (67 pass, 4 skip on the documented IndexedDB-blob harness
limitation) in ~1.5 minutes locally, using the exact command added here.
──────── chore(backend): satisfy cargo fmt
`checks.yml` runs `cargo fmt --check`, and it has been failing since the round-1
audit fixes: I gated those on `cargo build` and `cargo clippy` but never ran fmt,
so three files drifted then and eight more this round. Pure formatting — no
behaviour change; clippy stays at zero and all 70 backend tests still pass.
Worth noting for next time: clippy passing is not evidence fmt does.
──────── chore: satisfy prettier in frontend and e2e
`checks.yml` runs `npm run format:check` for both projects and both were failing.
- frontend/src/lib/ui-store.ts is mine, unformatted since the round-1 upload-queue
badge fix — the same miss as the rustfmt one: I gated on svelte-check and eslint
but never on format:check.
- e2e/loadtest/* and e2e/shots.mjs have been unformatted since 7758270 and are
unrelated to the audit work. Fixed here because they block the same gate and the
fix is mechanical; no behaviour change in either project.
Still red and deliberately NOT fixed here: `npm run lint` in the frontend reports
`svelte/prefer-svelte-reactivity` on routes/diashow/+page.svelte:208 (a mutable
`Set` where the rule wants `SvelteSet`), pre-existing since 5009590. That one is a
real reactivity change in code I have no test coverage for, so it belongs in its
own change rather than smuggled into a formatting commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,12 +9,12 @@ acting as the showcase display.
|
||||
|
||||
**Validate the shipping config.** We run at the real production defaults —
|
||||
compression concurrency (`COMPRESSION_WORKER_CONCURRENCY`, default **2**), DB
|
||||
pool (default **10**), quotas **on** — and answer: *does the app survive the
|
||||
event, and how far behind real-time does the diashow fall?*
|
||||
pool (default **10**), quotas **on** — and answer: _does the app survive the
|
||||
event, and how far behind real-time does the diashow fall?_
|
||||
|
||||
The headline metric is **pipeline latency**: time from an upload succeeding to
|
||||
its preview being ready (`upload-processed` SSE event) — i.e. *how long until the
|
||||
photo appears on the diashow*. A backlog that builds is fine; a backlog that
|
||||
its preview being ready (`upload-processed` SSE event) — i.e. _how long until the
|
||||
photo appears on the diashow_. A backlog that builds is fine; a backlog that
|
||||
**never drains** is a fail for a live event.
|
||||
|
||||
## Methodology: what we change vs. shipping
|
||||
@@ -86,13 +86,13 @@ compression backlog to drain** before reporting.
|
||||
|
||||
## What the flags mean
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| `✗ 5xx` | server errored under load — hard fail |
|
||||
| `✗ 507` | quota rejected uploads — disk/quota misconfig for the event size |
|
||||
| Flag | Meaning |
|
||||
| ------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `✗ 5xx` | server errored under load — hard fail |
|
||||
| `✗ 507` | quota rejected uploads — disk/quota misconfig for the event size |
|
||||
| `✗ backlog did not drain` | compression can't keep up even after uploads stop — diashow never catches up |
|
||||
| `⚠ pipeline p95 > 60s` | photos take >1 min to appear on the diashow at peak |
|
||||
| `⚠ SSE resyncs` | live consumers lagged the broadcast channel |
|
||||
| `⚠ pipeline p95 > 60s` | photos take >1 min to appear on the diashow at peak |
|
||||
| `⚠ SSE resyncs` | live consumers lagged the broadcast channel |
|
||||
|
||||
## Knobs
|
||||
|
||||
@@ -101,7 +101,7 @@ All via env (see header of `driver.mjs`): `LT_GUESTS`, `LT_IMAGES`,
|
||||
`LT_TRUNCATE`, `LT_DRAIN_TIMEOUT_SEC`, `LT_KEEP_RATELIMITS`, `LT_BASE`,
|
||||
`LT_APP_CONTAINER`, `LT_DB_CONTAINER`.
|
||||
|
||||
To later answer *"what config should I deploy?"*, re-run with a rebuilt stack
|
||||
To later answer _"what config should I deploy?"_, re-run with a rebuilt stack
|
||||
that sets `COMPRESSION_WORKER_CONCURRENCY` higher (boot-time env var in
|
||||
`docker-compose.test.yml`) and compare the pipeline-latency / drain numbers.
|
||||
|
||||
|
||||
@@ -10,17 +10,40 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const j = (r) => r.json();
|
||||
|
||||
const adminLogin = () =>
|
||||
fetch(`${BASE}/api/v1/admin/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: 'admin-test-pw' }) }).then(j).then((b) => b.jwt);
|
||||
fetch(`${BASE}/api/v1/admin/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'admin-test-pw' }),
|
||||
})
|
||||
.then(j)
|
||||
.then((b) => b.jwt);
|
||||
const join = (name) =>
|
||||
fetch(`${BASE}/api/v1/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: name }) }).then(j);
|
||||
fetch(`${BASE}/api/v1/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: name }),
|
||||
}).then(j);
|
||||
async function truncate(admin) {
|
||||
await fetch(`${BASE}/api/v1/admin/__truncate`, { method: 'POST', headers: { Authorization: `Bearer ${admin}` } });
|
||||
await fetch(`${BASE}/api/v1/admin/__truncate`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${admin}` },
|
||||
});
|
||||
}
|
||||
async function upload(jwt) {
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([readFileSync('/tmp/eventsnap-loadtest/photos/photo_000.jpg')], { type: 'image/jpeg' }), 'live.jpg');
|
||||
form.append(
|
||||
'file',
|
||||
new Blob([readFileSync('/tmp/eventsnap-loadtest/photos/photo_000.jpg')], {
|
||||
type: 'image/jpeg',
|
||||
}),
|
||||
'live.jpg'
|
||||
);
|
||||
form.append('caption', 'LIVE-PROBE');
|
||||
const r = await fetch(`${BASE}/api/v1/upload`, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` }, body: form });
|
||||
const r = await fetch(`${BASE}/api/v1/upload`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
body: form,
|
||||
});
|
||||
return (await r.json()).id;
|
||||
}
|
||||
|
||||
@@ -35,7 +58,9 @@ const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const page = await ctx.newPage();
|
||||
const streamReqs = [];
|
||||
page.on('request', (req) => { if (req.url().includes('/stream')) streamReqs.push(req.url().replace(BASE, '')); });
|
||||
page.on('request', (req) => {
|
||||
if (req.url().includes('/stream')) streamReqs.push(req.url().replace(BASE, ''));
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate((g) => {
|
||||
@@ -53,7 +78,9 @@ const emptyBefore = (await page.locator('text=Noch keine Beiträge').count()) >
|
||||
const streamOpened = streamReqs.length > 0;
|
||||
console.log(`\n1) direct /diashow on empty event:`);
|
||||
console.log(` "Noch keine Beiträge" shown: ${emptyBefore} (expected: true)`);
|
||||
console.log(` SSE stream opened: ${streamOpened} ${streamOpened ? '('+streamReqs.join(', ')+')' : ''} (expected: true — this is the fix)`);
|
||||
console.log(
|
||||
` SSE stream opened: ${streamOpened} ${streamOpened ? '(' + streamReqs.join(', ') + ')' : ''} (expected: true — this is the fix)`
|
||||
);
|
||||
|
||||
// Now a guest uploads a photo while the display is open.
|
||||
console.log(`\n2) guest uploads a photo (display already open)…`);
|
||||
@@ -65,7 +92,11 @@ for (let i = 0; i < 15; i++) {
|
||||
await sleep(1000);
|
||||
const stillEmpty = (await page.locator('text=Noch keine Beiträge').count()) > 0;
|
||||
const imgs = await page.locator('img').count();
|
||||
if (!stillEmpty && imgs > 0) { appeared = true; console.log(` photo appeared live after ~${i + 1}s (img rendered, placeholder gone)`); break; }
|
||||
if (!stillEmpty && imgs > 0) {
|
||||
appeared = true;
|
||||
console.log(` photo appeared live after ~${i + 1}s (img rendered, placeholder gone)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!appeared) console.log(` ✗ photo did NOT appear within 15s`);
|
||||
|
||||
|
||||
@@ -132,9 +132,19 @@ const truncate = (adminJwt) =>
|
||||
api('/admin/__truncate', { method: 'POST', token: adminJwt, expect: [204] });
|
||||
|
||||
const CAPTIONS = [
|
||||
'Was für ein magischer Tag 💍', 'Der erste Tanz 🕺', 'Prost! 🥂', 'Die Torte 🍰',
|
||||
'Feuerwerk 🎆', 'Beste Freunde 💕', 'Was für eine Stimmung! 🎉', 'Details 🌸',
|
||||
'Sonnenuntergang 🌅', 'Tanzfläche brennt 🔥', null, null, null,
|
||||
'Was für ein magischer Tag 💍',
|
||||
'Der erste Tanz 🕺',
|
||||
'Prost! 🥂',
|
||||
'Die Torte 🍰',
|
||||
'Feuerwerk 🎆',
|
||||
'Beste Freunde 💕',
|
||||
'Was für eine Stimmung! 🎉',
|
||||
'Details 🌸',
|
||||
'Sonnenuntergang 🌅',
|
||||
'Tanzfläche brennt 🔥',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
];
|
||||
const TAGS = ['hochzeit', 'liebe', 'party', 'tanzen', 'natur', 'feier', 'freunde', 'dessert'];
|
||||
|
||||
@@ -238,9 +248,12 @@ async function sampleResources() {
|
||||
const out = { ts: now() };
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', [
|
||||
'stats', '--no-stream', '--format',
|
||||
'stats',
|
||||
'--no-stream',
|
||||
'--format',
|
||||
'{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}',
|
||||
cfg.appContainer, cfg.dbContainer,
|
||||
cfg.appContainer,
|
||||
cfg.dbContainer,
|
||||
]);
|
||||
out.docker = stdout.trim();
|
||||
} catch (e) {
|
||||
@@ -259,8 +272,15 @@ async function sampleResources() {
|
||||
|
||||
function psql(sql) {
|
||||
return execFileAsync('docker', [
|
||||
'exec', cfg.dbContainer, 'psql', '-U', 'eventsnap_test', '-d', 'eventsnap_test',
|
||||
'-tAc', sql,
|
||||
'exec',
|
||||
cfg.dbContainer,
|
||||
'psql',
|
||||
'-U',
|
||||
'eventsnap_test',
|
||||
'-d',
|
||||
'eventsnap_test',
|
||||
'-tAc',
|
||||
sql,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -292,8 +312,13 @@ function summarize(nums) {
|
||||
const s = [...nums].sort((a, b) => a - b);
|
||||
const sum = s.reduce((a, b) => a + b, 0);
|
||||
return {
|
||||
n: s.length, min: s[0], max: s[s.length - 1], mean: Math.round(sum / s.length),
|
||||
p50: pct(s, 50), p95: pct(s, 95), p99: pct(s, 99),
|
||||
n: s.length,
|
||||
min: s[0],
|
||||
max: s[s.length - 1],
|
||||
mean: Math.round(sum / s.length),
|
||||
p50: pct(s, 50),
|
||||
p95: pct(s, 95),
|
||||
p99: pct(s, 99),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -490,7 +515,9 @@ async function main() {
|
||||
await sleep(cfg.windowSec * 1000 + 500);
|
||||
await Promise.all(burstPromises);
|
||||
clearInterval(ticker);
|
||||
console.log(`[run] all bursts issued. uploaded ${done}, ok ${uploads.filter((u) => u.status === 201).length}`);
|
||||
console.log(
|
||||
`[run] all bursts issued. uploaded ${done}, ok ${uploads.filter((u) => u.status === 201).length}`
|
||||
);
|
||||
|
||||
// Drain: wait for compression backlog to clear (SSE processed ⊇ successful ids)
|
||||
console.log('[drain] waiting for compression backlog to clear…');
|
||||
@@ -589,21 +616,28 @@ async function main() {
|
||||
console.log('\n' + '━'.repeat(72));
|
||||
console.log('RESULTS');
|
||||
console.log('━'.repeat(72));
|
||||
console.log(`uploads: ${okUploads.length}/${uploads.length} ok — byStatus ${JSON.stringify(byStatus)}`);
|
||||
console.log(
|
||||
`uploads: ${okUploads.length}/${uploads.length} ok — byStatus ${JSON.stringify(byStatus)}`
|
||||
);
|
||||
console.log(`upload latency ms: ${JSON.stringify(uploadLatency)}`);
|
||||
console.log(`pipeline latency ms (upload→preview ready): ${JSON.stringify(pipelineLatency)}`);
|
||||
console.log(`backlog drain: ${(drainMs / 1000).toFixed(1)}s, cleared=${report.drain.cleared}`);
|
||||
console.log(`final db compression status: ${JSON.stringify(finalCounts)}`);
|
||||
console.log(`sse: ${sseClients.length} conns, ${totalReconnects} reconnects, ${totalResyncs} resyncs`);
|
||||
console.log(
|
||||
`sse: ${sseClients.length} conns, ${totalReconnects} reconnects, ${totalResyncs} resyncs`
|
||||
);
|
||||
console.log(`\nfull report → ${outPath}`);
|
||||
|
||||
// Heuristic pass/fail flags (validate shipping config)
|
||||
const flags = [];
|
||||
const err5xx = Object.entries(byStatus).filter(([s]) => +s >= 500).reduce((a, [, n]) => a + n, 0);
|
||||
const err5xx = Object.entries(byStatus)
|
||||
.filter(([s]) => +s >= 500)
|
||||
.reduce((a, [, n]) => a + n, 0);
|
||||
if (err5xx > 0) flags.push(`✗ ${err5xx} server errors (5xx)`);
|
||||
if (byStatus['507']) flags.push(`✗ ${byStatus['507']} quota rejections (507)`);
|
||||
if (byStatus['413']) flags.push(`⚠ ${byStatus['413']} too-large (413)`);
|
||||
if (byStatus['429']) flags.push(`⚠ ${byStatus['429']} rate-limited (429) — unexpected with limits off`);
|
||||
if (byStatus['429'])
|
||||
flags.push(`⚠ ${byStatus['429']} rate-limited (429) — unexpected with limits off`);
|
||||
if (!report.drain.cleared) flags.push(`✗ backlog did NOT drain within ${cfg.drainTimeoutSec}s`);
|
||||
if (totalResyncs > sseClients.length) flags.push(`⚠ ${totalResyncs} SSE resyncs (consumer lag)`);
|
||||
if (pipelineLatency && pipelineLatency.p95 > 60000)
|
||||
|
||||
140
e2e/shots.mjs
140
e2e/shots.mjs
@@ -4,24 +4,44 @@ import { chromium, devices } from '@playwright/test';
|
||||
import { Client } from 'pg';
|
||||
import { readFileSync, mkdirSync } from 'node:fs';
|
||||
|
||||
|
||||
const BASE = 'http://localhost:3101';
|
||||
const PHOTOS = '/tmp/eventsnap-shots/photos';
|
||||
const OUT = '/tmp/eventsnap-shots';
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const api = (path, opts = {}) =>
|
||||
fetch(`${BASE}/api/v1${path}`, opts).then(async (r) => ({ status: r.status, body: await r.text().then((t) => { try { return JSON.parse(t); } catch { return t; } }) }));
|
||||
fetch(`${BASE}/api/v1${path}`, opts).then(async (r) => ({
|
||||
status: r.status,
|
||||
body: await r.text().then((t) => {
|
||||
try {
|
||||
return JSON.parse(t);
|
||||
} catch {
|
||||
return t;
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
const authHeaders = (jwt, json = true) => ({ Authorization: `Bearer ${jwt}`, ...(json ? { 'Content-Type': 'application/json' } : {}) });
|
||||
const authHeaders = (jwt, json = true) => ({
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
...(json ? { 'Content-Type': 'application/json' } : {}),
|
||||
});
|
||||
|
||||
async function adminLogin() {
|
||||
const r = await api('/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: 'admin-test-pw' }) });
|
||||
const r = await api('/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'admin-test-pw' }),
|
||||
});
|
||||
return r.body.jwt;
|
||||
}
|
||||
async function joinGuest(name) {
|
||||
const r = await api('/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: name }) });
|
||||
if (r.status !== 201) throw new Error('join failed ' + name + ' ' + r.status + ' ' + JSON.stringify(r.body));
|
||||
const r = await api('/join', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: name }),
|
||||
});
|
||||
if (r.status !== 201)
|
||||
throw new Error('join failed ' + name + ' ' + r.status + ' ' + JSON.stringify(r.body));
|
||||
return r.body; // {jwt,pin,user_id}
|
||||
}
|
||||
async function upload(jwt, file, caption, hashtags) {
|
||||
@@ -29,7 +49,11 @@ async function upload(jwt, file, caption, hashtags) {
|
||||
form.append('file', new Blob([readFileSync(file)], { type: 'image/jpeg' }), 'photo.jpg');
|
||||
if (caption) form.append('caption', caption);
|
||||
if (hashtags) form.append('hashtags', hashtags);
|
||||
const r = await fetch(`${BASE}/api/v1/upload`, { method: 'POST', headers: authHeaders(jwt, false), body: form });
|
||||
const r = await fetch(`${BASE}/api/v1/upload`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(jwt, false),
|
||||
body: form,
|
||||
});
|
||||
if (r.status !== 201) throw new Error('upload failed ' + r.status + ' ' + (await r.text()));
|
||||
return (await r.json()).id;
|
||||
}
|
||||
@@ -37,16 +61,34 @@ async function like(jwt, id) {
|
||||
await fetch(`${BASE}/api/v1/upload/${id}/like`, { method: 'POST', headers: authHeaders(jwt) });
|
||||
}
|
||||
async function comment(jwt, id, body) {
|
||||
await fetch(`${BASE}/api/v1/upload/${id}/comments`, { method: 'POST', headers: authHeaders(jwt), body: JSON.stringify({ body }) });
|
||||
await fetch(`${BASE}/api/v1/upload/${id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(jwt),
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
}
|
||||
|
||||
const pg = () => new Client({ host: 'localhost', port: 55432, user: 'eventsnap_test', password: 'eventsnap_test', database: 'eventsnap_test' });
|
||||
const pg = () =>
|
||||
new Client({
|
||||
host: 'localhost',
|
||||
port: 55432,
|
||||
user: 'eventsnap_test',
|
||||
password: 'eventsnap_test',
|
||||
database: 'eventsnap_test',
|
||||
});
|
||||
|
||||
async function truncate(adminJwt) {
|
||||
await fetch(`${BASE}/api/v1/admin/__truncate`, { method: 'POST', headers: authHeaders(adminJwt) });
|
||||
await fetch(`${BASE}/api/v1/admin/__truncate`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(adminJwt),
|
||||
});
|
||||
}
|
||||
async function patchConfig(adminJwt, patch) {
|
||||
await fetch(`${BASE}/api/v1/admin/config`, { method: 'PATCH', headers: authHeaders(adminJwt), body: JSON.stringify(patch) });
|
||||
await fetch(`${BASE}/api/v1/admin/config`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(adminJwt),
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- SEED ----
|
||||
@@ -54,9 +96,27 @@ console.log('[seed] admin login + reset');
|
||||
let admin = await adminLogin();
|
||||
await truncate(admin);
|
||||
admin = await adminLogin();
|
||||
await patchConfig(admin, { rate_limits_enabled: 'false', upload_rate_enabled: 'false', feed_rate_enabled: 'false', export_rate_enabled: 'false', join_rate_enabled: 'false', quota_enabled: 'false', storage_quota_enabled: 'false', upload_count_quota_enabled: 'false' });
|
||||
await patchConfig(admin, {
|
||||
rate_limits_enabled: 'false',
|
||||
upload_rate_enabled: 'false',
|
||||
feed_rate_enabled: 'false',
|
||||
export_rate_enabled: 'false',
|
||||
join_rate_enabled: 'false',
|
||||
quota_enabled: 'false',
|
||||
storage_quota_enabled: 'false',
|
||||
upload_count_quota_enabled: 'false',
|
||||
});
|
||||
|
||||
const guests = ['Anna Bauer', 'Lukas Weber', 'Mia Schulz', 'Jonas Fischer', 'Emma Wagner', 'Ben Hoffmann', 'Sophie Klein', 'Paul Richter'];
|
||||
const guests = [
|
||||
'Anna Bauer',
|
||||
'Lukas Weber',
|
||||
'Mia Schulz',
|
||||
'Jonas Fischer',
|
||||
'Emma Wagner',
|
||||
'Ben Hoffmann',
|
||||
'Sophie Klein',
|
||||
'Paul Richter',
|
||||
];
|
||||
const accounts = {};
|
||||
for (const g of guests) accounts[g] = await joinGuest(g);
|
||||
console.log('[seed] joined', guests.length, 'guests');
|
||||
@@ -94,13 +154,23 @@ await comment(accounts['Ben Hoffmann'].jwt, ids[1], 'Was ein Abend!');
|
||||
console.log('[seed] likes + comments done');
|
||||
|
||||
// Make one guest a host so /host renders populated
|
||||
await fetch(`${BASE}/api/v1/host/users/${accounts['Anna Bauer'].user_id}/role`, { method: 'PATCH', headers: authHeaders(admin), body: JSON.stringify({ role: 'host' }) });
|
||||
await fetch(`${BASE}/api/v1/host/users/${accounts['Anna Bauer'].user_id}/role`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(admin),
|
||||
body: JSON.stringify({ role: 'host' }),
|
||||
});
|
||||
|
||||
// Wait for compression to finish so previews render
|
||||
const c = pg(); await c.connect();
|
||||
const c = pg();
|
||||
await c.connect();
|
||||
for (let t = 0; t < 40; t++) {
|
||||
const r = await c.query(`SELECT COUNT(*)::int AS n FROM upload WHERE compression_status <> 'done' AND deleted_at IS NULL`);
|
||||
if (r.rows[0].n === 0) { console.log('[seed] compression done'); break; }
|
||||
const r = await c.query(
|
||||
`SELECT COUNT(*)::int AS n FROM upload WHERE compression_status <> 'done' AND deleted_at IS NULL`
|
||||
);
|
||||
if (r.rows[0].n === 0) {
|
||||
console.log('[seed] compression done');
|
||||
break;
|
||||
}
|
||||
await new Promise((res) => setTimeout(res, 500));
|
||||
}
|
||||
await c.end();
|
||||
@@ -113,17 +183,20 @@ const shot = async (label, theme, who, route, prep) => {
|
||||
const page = await ctx.newPage();
|
||||
// Seed localStorage on the origin
|
||||
await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(({ jwt, pin, uid, name, theme, mode }) => {
|
||||
localStorage.setItem('eventsnap_theme', theme);
|
||||
localStorage.setItem('eventsnap_data_mode', mode);
|
||||
if (jwt) {
|
||||
localStorage.setItem('eventsnap_jwt', jwt);
|
||||
localStorage.setItem('eventsnap_pin', pin);
|
||||
localStorage.setItem('eventsnap_user_id', uid);
|
||||
localStorage.setItem('eventsnap_display_name', name);
|
||||
}
|
||||
localStorage.setItem('eventsnap_guide_seen', '1');
|
||||
}, { jwt: who?.jwt, pin: who?.pin, uid: who?.user_id, name: who?.name, theme, mode: 'saver' });
|
||||
await page.evaluate(
|
||||
({ jwt, pin, uid, name, theme, mode }) => {
|
||||
localStorage.setItem('eventsnap_theme', theme);
|
||||
localStorage.setItem('eventsnap_data_mode', mode);
|
||||
if (jwt) {
|
||||
localStorage.setItem('eventsnap_jwt', jwt);
|
||||
localStorage.setItem('eventsnap_pin', pin);
|
||||
localStorage.setItem('eventsnap_user_id', uid);
|
||||
localStorage.setItem('eventsnap_display_name', name);
|
||||
}
|
||||
localStorage.setItem('eventsnap_guide_seen', '1');
|
||||
},
|
||||
{ jwt: who?.jwt, pin: who?.pin, uid: who?.user_id, name: who?.name, theme, mode: 'saver' }
|
||||
);
|
||||
await page.goto(`${BASE}${route}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1200);
|
||||
if (prep) await prep(page);
|
||||
@@ -141,10 +214,17 @@ for (const theme of ['light', 'dark']) {
|
||||
await shot('01-join', theme, null, '/join');
|
||||
await shot('02-feed-list', theme, hostWho, '/feed');
|
||||
await shot('03-feed-grid', theme, hostWho, '/feed', async (p) => {
|
||||
await p.getByLabel('Rasteransicht').click().catch(() => {});
|
||||
await p
|
||||
.getByLabel('Rasteransicht')
|
||||
.click()
|
||||
.catch(() => {});
|
||||
});
|
||||
await shot('04-lightbox', theme, hostWho, '/feed', async (p) => {
|
||||
await p.locator('img').first().click().catch(() => {});
|
||||
await p
|
||||
.locator('img')
|
||||
.first()
|
||||
.click()
|
||||
.catch(() => {});
|
||||
await p.waitForTimeout(500);
|
||||
});
|
||||
await shot('05-account', theme, hostWho, '/account');
|
||||
|
||||
@@ -142,3 +142,65 @@ test.describe('Rate limits — guests behind a shared NAT', () => {
|
||||
expect((await mintAndFetch(host.jwt)).status).not.toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Rate limits — /recover name cycling', () => {
|
||||
test('cycling names from one IP hits the ceiling, while one name is still throttled', async ({
|
||||
api,
|
||||
adminToken,
|
||||
}) => {
|
||||
// /recover is keyed `recover:{ip}:{name}` — right for its job (stopping someone who
|
||||
// knows a display name from burning the victim's 3-strike PIN counter), but the name is
|
||||
// ATTACKER-CHOSEN, so cycling names minted a fresh bucket every time. Behind it sits a
|
||||
// cost-12 bcrypt verify, including an unconditional throwaway one for unknown names, so
|
||||
// a name generator was the cheapest way to make the server hash forever.
|
||||
//
|
||||
// Squeeze the ceiling so the flood is reproducible without firing 30+ requests.
|
||||
await api.patchConfig(adminToken, {
|
||||
rate_limits_enabled: 'true',
|
||||
recover_rate_enabled: 'true',
|
||||
recover_ip_rate_per_min: '5',
|
||||
});
|
||||
|
||||
const attempt = (name: string) =>
|
||||
fetch(`${BASE}/api/v1/recover`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: name, pin: '0000' }),
|
||||
});
|
||||
|
||||
// Every name is distinct, so the per-name bucket can never fire — only the ceiling can.
|
||||
const codes: number[] = [];
|
||||
for (let i = 0; i < 12; i++) codes.push((await attempt(`Unbekannt${i}_${Date.now()}`)).status);
|
||||
|
||||
expect(
|
||||
codes.filter((c) => c === 429).length,
|
||||
'name cycling must be capped by the per-IP ceiling'
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
const throttled = await attempt(`Unbekannt99_${Date.now()}`);
|
||||
expect(throttled.status).toBe(429);
|
||||
expect(Number(throttled.headers.get('retry-after'))).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('the per-name bucket still protects a real account', async ({ api, adminToken, guest }) => {
|
||||
// The ceiling must not have REPLACED the anti-guessing control. With a generous ceiling,
|
||||
// repeated wrong PINs against ONE name must still be shut down by the per-name bucket.
|
||||
const victim = await guest('PinVictim');
|
||||
await api.patchConfig(adminToken, {
|
||||
rate_limits_enabled: 'true',
|
||||
recover_rate_enabled: 'true',
|
||||
recover_ip_rate_per_min: '1000',
|
||||
});
|
||||
|
||||
const attempt = () =>
|
||||
fetch(`${BASE}/api/v1/recover`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: victim.displayName, pin: '9999' }),
|
||||
});
|
||||
|
||||
const codes: number[] = [];
|
||||
for (let i = 0; i < 7; i++) codes.push((await attempt()).status);
|
||||
expect(codes.at(-1), 'guessing one name must still be throttled').toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
172
e2e/specs/03-feed/video-playback.spec.ts
Normal file
172
e2e/specs/03-feed/video-playback.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Regression guard — videos must actually play.
|
||||
*
|
||||
* Two independent defects made every video unplayable, and nothing in the suite covered
|
||||
* either one (no test anywhere played media or asserted a `<video>` src):
|
||||
*
|
||||
* 1. The lightbox fed `<video>` the URL from `pickMediaUrl`, which is mime-agnostic.
|
||||
* Compression only ever produces a *thumbnail* for a video — one ffmpeg frame — so in
|
||||
* the DEFAULT saver mode the element's src was `/api/v1/upload/{id}/thumbnail`: a JPEG,
|
||||
* served as image/jpeg with nosniff so the browser can't even sniff its way out.
|
||||
* Chromium reported DEMUXER_ERROR_COULD_NOT_OPEN.
|
||||
*
|
||||
* 2. `stream_media_file` ignored `Range` entirely — always 200 with the whole body, never
|
||||
* an Accept-Ranges or Content-Range. iOS Safari opens every `<video>` with a
|
||||
* `Range: bytes=0-1` probe and abandons the load without a 206, so video failed on the
|
||||
* app's primary platform even in `original` data mode.
|
||||
*
|
||||
* Fixing either alone still leaves video broken, so both are asserted here.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { uploadRaw } from '../../helpers/upload-client';
|
||||
import { BASE } from '../../helpers/env';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const SAMPLE_MP4 = join(process.cwd(), 'fixtures', 'media', 'sample.mp4');
|
||||
|
||||
async function seedVideo(jwt: string): Promise<string> {
|
||||
const res = await uploadRaw(jwt, readFileSync(SAMPLE_MP4), {
|
||||
filename: 'clip.mp4',
|
||||
contentType: 'video/mp4',
|
||||
caption: 'ein Video',
|
||||
});
|
||||
if (res.status !== 201) throw new Error(`video seed failed: ${res.status}`);
|
||||
return ((await res.json()) as { id: string }).id;
|
||||
}
|
||||
|
||||
test.describe('Video — the lightbox plays it', () => {
|
||||
test('the <video> src is the original, not the thumbnail JPEG', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('VideoWatcher');
|
||||
const id = await seedVideo(g.jwt);
|
||||
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
|
||||
const card = page.locator('article').first();
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
|
||||
|
||||
const video = page.locator('video');
|
||||
await expect(video).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The bug in one assertion: this was `/thumbnail` in the default data mode.
|
||||
await expect(video).toHaveAttribute('src', `/api/v1/upload/${id}/original`);
|
||||
|
||||
// The poster SHOULD still be the thumbnail — that's what it's for.
|
||||
await expect(video).toHaveAttribute('poster', `/api/v1/upload/${id}/thumbnail`);
|
||||
|
||||
// And the browser must accept the bytes as media. preload="none" means nothing is
|
||||
// fetched until we ask, so drive a load explicitly and wait for metadata.
|
||||
const readyState = await video.evaluate(async (el: HTMLVideoElement) => {
|
||||
el.preload = 'metadata';
|
||||
el.load();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (el.readyState > 0) return resolve();
|
||||
el.addEventListener('loadedmetadata', () => resolve(), { once: true });
|
||||
el.addEventListener('error', () => resolve(), { once: true });
|
||||
setTimeout(resolve, 15_000);
|
||||
});
|
||||
return { ready: el.readyState, err: el.error?.message ?? null };
|
||||
});
|
||||
expect(readyState.err, `the browser rejected the media: ${readyState.err}`).toBeNull();
|
||||
expect(readyState.ready, 'video metadata must load').toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('the video body is not downloaded before the user presses play', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
// saver mode exists to protect a guest's mobile data, and there is no smaller video
|
||||
// derivative to fall back to — so opening the lightbox must not pull the file down.
|
||||
//
|
||||
// Asserting "no request at all" would be wrong: WebKit opens a connection for a
|
||||
// preload="none" <video> and immediately ABORTS it (observed: GET with no Range,
|
||||
// response status 0, nothing transferred), whereas Chromium issues nothing. The
|
||||
// portable guarantee — and the one that actually protects the data plan — is that no
|
||||
// response carrying the body ever completes. Dropping preload="none" fails this.
|
||||
const g = await guest('VideoThrifty');
|
||||
const id = await seedVideo(g.jwt);
|
||||
|
||||
await signIn(page, g);
|
||||
const delivered: string[] = [];
|
||||
page.on('response', (r) => {
|
||||
if (!r.url().includes(`/upload/${id}/original`)) return;
|
||||
// status 0 = aborted before any bytes landed.
|
||||
if (r.status() === 200 || r.status() === 206) {
|
||||
delivered.push(`${r.status()} len=${r.headers()['content-length'] ?? '?'}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/feed');
|
||||
const card = page.locator('article').first();
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
|
||||
await expect(page.locator('video')).toBeVisible({ timeout: 10_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
expect(delivered, 'no video bytes may be delivered before play').toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Media — HTTP Range', () => {
|
||||
test('a range request is answered 206 with the right slice', async ({ guest }) => {
|
||||
const g = await guest('RangeReader');
|
||||
const id = await seedVideo(g.jwt);
|
||||
const url = `${BASE}/api/v1/upload/${id}/original`;
|
||||
|
||||
const full = await fetch(url);
|
||||
expect(full.status).toBe(200);
|
||||
expect(full.headers.get('accept-ranges'), 'clients must be told seeking works').toBe('bytes');
|
||||
const total = Number(full.headers.get('content-length'));
|
||||
expect(total).toBeGreaterThan(0);
|
||||
|
||||
// The exact probe iOS Safari opens a <video> with. Two bytes, inclusive.
|
||||
const probe = await fetch(url, { headers: { Range: 'bytes=0-1' } });
|
||||
expect(probe.status, 'iOS abandons the load without a 206').toBe(206);
|
||||
expect(probe.headers.get('content-range')).toBe(`bytes 0-1/${total}`);
|
||||
expect(Number(probe.headers.get('content-length'))).toBe(2);
|
||||
expect((await probe.arrayBuffer()).byteLength).toBe(2);
|
||||
|
||||
// A mid-file seek must return the matching slice, not the whole body.
|
||||
const mid = await fetch(url, { headers: { Range: 'bytes=10-19' } });
|
||||
expect(mid.status).toBe(206);
|
||||
expect(mid.headers.get('content-range')).toBe(`bytes 10-19/${total}`);
|
||||
const midBytes = Buffer.from(await mid.arrayBuffer());
|
||||
expect(midBytes.byteLength).toBe(10);
|
||||
expect(midBytes).toEqual(Buffer.from(await full.arrayBuffer()).subarray(10, 20));
|
||||
|
||||
// Open-ended range runs to EOF.
|
||||
const tail = await fetch(url, { headers: { Range: `bytes=${total - 5}-` } });
|
||||
expect(tail.status).toBe(206);
|
||||
expect(Number(tail.headers.get('content-length'))).toBe(5);
|
||||
|
||||
// Past EOF must be 416 — answering 200 makes a player re-request forever.
|
||||
const bad = await fetch(url, { headers: { Range: `bytes=${total + 10}-` } });
|
||||
expect(bad.status).toBe(416);
|
||||
expect(bad.headers.get('content-range')).toBe(`bytes */${total}`);
|
||||
});
|
||||
|
||||
test('image derivatives are range-capable too', async ({ guest, db }) => {
|
||||
// Same helper serves all four media routes, so the guarantee is uniform.
|
||||
const g = await guest('RangeImages');
|
||||
const res = await uploadRaw(
|
||||
g.jwt,
|
||||
readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')),
|
||||
{ filename: 'r.jpg', contentType: 'image/jpeg' }
|
||||
);
|
||||
const { id } = (await res.json()) as { id: string };
|
||||
await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');
|
||||
|
||||
const preview = await fetch(`${BASE}/api/v1/upload/${id}/preview`, {
|
||||
headers: { Range: 'bytes=0-9' },
|
||||
});
|
||||
expect(preview.status).toBe(206);
|
||||
expect(Number(preview.headers.get('content-length'))).toBe(10);
|
||||
});
|
||||
});
|
||||
102
e2e/specs/04-host/role-identity-reset.spec.ts
Normal file
102
e2e/specs/04-host/role-identity-reset.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Regression guard — the role must follow the identity, not the tab.
|
||||
*
|
||||
* The `role` store is a module-level singleton seeded ONCE at import. `goto()` is a
|
||||
* client-side navigation, so leaving and re-joining in the same tab re-imports nothing and
|
||||
* re-runs no `onMount` — the previous user's role simply stayed. A host who left and a
|
||||
* guest who then joined kept `isStaff === true` and were offered "🚫 Beitrag entfernen" on
|
||||
* other people's photos. The backend 403s the delete, so it was a false affordance rather
|
||||
* than a privilege escalation, but `/feed` never fetched `/me/context`, so it never
|
||||
* self-corrected either — it survived until a hard reload.
|
||||
*
|
||||
* The mirror case matters just as much and is easier to forget: a guest who recovers into a
|
||||
* host account must GAIN the affordance without a reload.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
import { JoinPage } from '../../page-objects';
|
||||
|
||||
const REMOVE = /beitrag entfernen/i;
|
||||
|
||||
test.describe('Role — follows the identity across a same-tab switch', () => {
|
||||
test('a guest joining after a host leaves does NOT inherit host actions', async ({
|
||||
page,
|
||||
host,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
// Someone else's photo — the only kind the removal action is offered on.
|
||||
const author = await guest('RoleAuthor');
|
||||
await seedUpload(author.jwt);
|
||||
|
||||
// 1. Host is signed in and DOES see the moderation action. Establishing this first is
|
||||
// what makes the negative assertion below meaningful.
|
||||
await signIn(page, host);
|
||||
await page.goto('/feed');
|
||||
const card = page.locator('article').filter({ hasText: author.displayName }).first();
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.getByRole('button', { name: 'Mehr Aktionen' }).click();
|
||||
await expect(page.getByRole('button', { name: REMOVE })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// 2. Host leaves, in-app — no reload. This is the path "Event verlassen" takes.
|
||||
await page.goto('/account');
|
||||
await page.getByRole('button', { name: /event verlassen/i }).click();
|
||||
const confirm = page.getByTestId('confirm-sheet-confirm');
|
||||
if (await confirm.isVisible().catch(() => false)) await confirm.click();
|
||||
await page.waitForURL('**/join', { timeout: 10_000 });
|
||||
|
||||
// 3. A brand-new guest joins in the same tab — the real flow, PIN modal and all.
|
||||
const join = new JoinPage(page);
|
||||
await join.joinAs(`Nachzuegler${Date.now() % 100000}`);
|
||||
await join.continueToFeed();
|
||||
await expect(page).toHaveURL(/\/feed$/, { timeout: 15_000 });
|
||||
|
||||
// 4. They must NOT be offered moderation on someone else's photo.
|
||||
const card2 = page.locator('article').filter({ hasText: author.displayName }).first();
|
||||
await expect(card2).toBeVisible({ timeout: 15_000 });
|
||||
await card2.getByRole('button', { name: 'Mehr Aktionen' }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: REMOVE }),
|
||||
'a fresh guest must not inherit the previous user’s role'
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a guest who recovers into a host account GAINS host actions without a reload', async ({
|
||||
page,
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
// The mirror. If the fix only cleared the role it would pass the test above and still
|
||||
// leave a real host with no moderation until they reloaded.
|
||||
const author = await guest('RoleAuthor2');
|
||||
await seedUpload(author.jwt);
|
||||
|
||||
const futureHost = await guest('WillBeHost');
|
||||
await signIn(page, futureHost);
|
||||
await page.goto('/feed');
|
||||
const card = page.locator('article').filter({ hasText: author.displayName }).first();
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.getByRole('button', { name: 'Mehr Aktionen' }).click();
|
||||
await expect(page.getByRole('button', { name: REMOVE })).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Promote them server-side. Their resident JWT still claims `role: guest`.
|
||||
await api.setRole(adminToken, futureHost.userId, 'host');
|
||||
const claim = JSON.parse(Buffer.from(futureHost.jwt.split('.')[1], 'base64').toString());
|
||||
expect(claim.role, 'the token must still be stale for this to prove anything').toBe('guest');
|
||||
|
||||
// A plain in-app navigation back to the feed must pick up the live role.
|
||||
await page.goto('/account');
|
||||
await page.goto('/feed');
|
||||
const card2 = page.locator('article').filter({ hasText: author.displayName }).first();
|
||||
await expect(card2).toBeVisible({ timeout: 15_000 });
|
||||
await card2.getByRole('button', { name: 'Mehr Aktionen' }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: REMOVE }),
|
||||
'the live role from /me/context must reach the feed'
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
117
e2e/specs/06-export/exif-orientation.spec.ts
Normal file
117
e2e/specs/06-export/exif-orientation.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Regression guard — the keepsake must not be sideways.
|
||||
*
|
||||
* Round 1 taught the compression worker to apply EXIF orientation, which fixed the live app
|
||||
* (feed preview + diashow display). The export worker was missed: it does NOT reuse those
|
||||
* derivatives — it re-decodes the originals itself with `image::open`, which ignores the
|
||||
* orientation tag — and then re-encodes to JPEG, which drops the tag, so the viewer has no
|
||||
* way to recover it.
|
||||
*
|
||||
* The resulting damage was oddly shaped, which is what made it read as a viewer bug:
|
||||
* - Gallery.zip originals → correct (byte-copied, EXIF intact)
|
||||
* - Memories viewer grid thumbnails → ALWAYS sideways
|
||||
* - Memories viewer full image >5 MB → sideways (re-encoded at 2000px)
|
||||
* - Memories viewer full image ≤5 MB → correct (streamed byte-for-byte)
|
||||
*
|
||||
* So clicking a small photo silently "fixed" it and a large one didn't. This pins the
|
||||
* thumbnail, which is the path every photo takes.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { uploadRaw } from '../../helpers/upload-client';
|
||||
import { BASE } from '../../helpers/env';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// 40x20 landscape pixels tagged Orientation=6 ("rotate 90° CW to display"), so anything
|
||||
// that honours the tag emits a PORTRAIT derivative.
|
||||
const EXIF_FIXTURE = join(process.cwd(), 'fixtures', 'media', 'portrait-exif6.jpg');
|
||||
|
||||
/** Pixel dimensions from a JPEG's SOF marker — avoids an image dep for one assertion. */
|
||||
function jpegSize(buf: Buffer): { width: number; height: number } {
|
||||
let i = 2;
|
||||
while (i < buf.length) {
|
||||
if (buf[i] !== 0xff) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const marker = buf[i + 1];
|
||||
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
||||
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
|
||||
}
|
||||
i += 2 + buf.readUInt16BE(i + 2);
|
||||
}
|
||||
throw new Error('no SOF marker found — not a JPEG?');
|
||||
}
|
||||
|
||||
test.describe('Export — EXIF orientation in the keepsake', () => {
|
||||
test('the Memories viewer thumbnail of a rotated photo is upright', async ({ host, db }) => {
|
||||
test.setTimeout(90_000);
|
||||
const bearer = { Authorization: `Bearer ${host.jwt}` };
|
||||
|
||||
const src = readFileSync(EXIF_FIXTURE);
|
||||
// Sanity: the SOURCE really is stored landscape, or this test proves nothing.
|
||||
const srcSize = jpegSize(src);
|
||||
expect(srcSize.width).toBeGreaterThan(srcSize.height);
|
||||
|
||||
const up = await uploadRaw(host.jwt, src, {
|
||||
filename: 'hochkant.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
caption: 'hochkant',
|
||||
});
|
||||
expect(up.status).toBe(201);
|
||||
const { id } = (await up.json()) as { id: string };
|
||||
await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');
|
||||
|
||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||||
method: 'POST',
|
||||
headers: bearer,
|
||||
});
|
||||
expect(rel.status).toBe(204);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
|
||||
return (await res.json()).html?.status;
|
||||
},
|
||||
{ timeout: 60_000, intervals: [500] }
|
||||
)
|
||||
.toBe('done');
|
||||
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||
method: 'POST',
|
||||
headers: bearer,
|
||||
});
|
||||
const { ticket } = (await ticketRes.json()) as { ticket: string };
|
||||
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
|
||||
expect(dl.status).toBe(200);
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'eventsnap-exif-'));
|
||||
try {
|
||||
const zipPath = join(dir, 'Memories.zip');
|
||||
writeFileSync(zipPath, Buffer.from(await dl.arrayBuffer()));
|
||||
|
||||
const entries = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' })
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
const thumbEntry = entries.find((e) => e.includes(`${id}_thumb`));
|
||||
expect(thumbEntry, `no thumbnail for ${id} in Memories.zip`).toBeTruthy();
|
||||
|
||||
// `-p` streams the entry to stdout. Extracting to disk instead fails with EACCES:
|
||||
// the archive preserves the container's file mode, which the test user can't read.
|
||||
const thumb = execFileSync('unzip', ['-p', zipPath, thumbEntry!], {
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
const { width, height } = jpegSize(thumb);
|
||||
|
||||
expect(
|
||||
height,
|
||||
`the keepsake grid thumbnail is ${width}x${height} — EXIF orientation was not applied`
|
||||
).toBeGreaterThan(width);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user