A 6-lens multi-agent review (103 agents; every finding adversarially verified by three
refute-by-default skeptics) confirmed the epoch redesign is sound — the core invariant
holds — and found 13 real defects around it. All are fixed here.
The redesign's invariant was re-verified and stands: an accepted upload is always in the
keepsake, and a downloadable keepsake always reflects the most recent release.
But a WEAKER adjacent invariant did NOT hold — "a downloadable keepsake reflects the
current content" — and that is the theme of the biggest fixes.
CONTENT REMOVAL NOW ALWAYS REACHES THE KEEPSAKE
Regeneration was wired only to host deletes. Ban, unban, guest self-delete and guest
comment-delete did not trigger it — yet the export query filters `is_banned = FALSE`,
so the pipeline already agreed that content must not be there. Ban someone for abusive
content after release and every guest kept downloading an archive containing it.
All five removal paths now invalidate and rebuild. `query_comments` also gained the
banned/hidden filter it was missing entirely (the ZIP had it; the viewer did not).
Each removal is now ATOMIC with its invalidation (one tx, models made executor-generic).
Previously the delete committed in its own tx and the regeneration in a second: a dropped
handler future between them left the taken-down photo permanently downloadable, and
nothing could notice — the keepsake still looked complete and the host could no longer
even find the upload to retry.
NO MORE TAKEDOWN STORM
Every removal spawned two uncancellable full exports. A superseded worker was INERT, not
STOPPED: it still ran every ffmpeg spawn and image resize and wrote a whole archive before
discovering it had lost. Five deletes left ten workers alive, each holding a
full-gallery-sized temp, in a 1 GB container.
Now: a debounce before `claim_job` (a superseded worker fails its claim and does ZERO
work, collapsing a burst into one export), plus `update_progress` — which already ran
exactly the right liveness predicate and threw the answer away — returning it so both file
loops bail the instant they are retired. Moderating a comment no longer rebuilds the
multi-GB ZIP either: the ZIP is media-only, so it is carried forward, with prune taught to
protect any file a live row still references.
AN ESCAPE HATCH
A failed export was terminal at runtime: release_gallery refuses an already-released event,
recovery only runs at boot, and there was no retry route — the only outs were restarting the
container or reopening uploads to every guest. Added POST /host/export/rebuild. Also widened
mark_failed's guard to `status IN ('running','pending')`: when claim_job itself ERRORED, the
row was still `pending`, so the failure was never recorded and the job sat at 0% forever.
SILENT INVALIDATION
Retiring the epoch 404s the download instantly, but nothing told the clients — guests kept
seeing an enabled download button through the whole rebuild. Now broadcasts an invalidation.
And the download was a top-level <a href>: a 404 (or a 429 from the per-IP export limit that
everyone on the venue WiFi shares) NAVIGATED THE USER OUT OF THE PWA onto raw JSON. It now
targets a hidden iframe, so an error response is harmless.
ALSO
- The HTML export still had the exists()-then-open TOCTOU the ZIP path was fixed to remove,
at three sites, two with a hard `?` that failed the ENTIRE viewer. It is reachable: the
compression worker hard-deletes an original when a transcode fails and can still be running
when the gallery is released.
- Crash-orphaned .tmp files were immortal (prune deliberately skips .tmp). Swept at boot,
where it is unambiguously safe.
- export_status read the epoch in one statement and used it in the next; now one query.
- DiskCache::snapshot IGNORED its path argument on a cache hit, returning whatever filesystem
was measured last. Latent only because both callers pass media_path — it would have silently
misreported the moment anyone measured the exports volume. Now keyed by path.
- Corrected two comments that asserted safety properties the code does not have (claim_job
does NOT prevent a retired-epoch claim — retirement is enforced at READ time; and a
multi-replica reap is NOT contained, it can corrupt the keepsake). Added a rollback runbook
to migration 014, the repo's first destructive migration.
TESTS
The regression guard for the headline FOR SHARE fix was probabilistic — it could pass on
broken code if the interleaving fell the wrong way. It is now STRUCTURAL: the upload is held
open mid-body with its pre-flight check already passed, so the release provably lands inside
the exact window. Verified 5/5 FAILING (201 instead of 403) with the fix reverted, 5/5
passing with it.
Verified: backend 40 tests, svelte-check 0 errors, e2e typecheck clean,
e2e 160 passed / 1 skipped on chromium-desktop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EventSnap E2E Suite
Playwright-driven end-to-end tests for the EventSnap stack. The suite spins
up an isolated docker-compose stack on ports :3101 (Caddy → frontend +
backend) and :55432 (Postgres), and exercises the SvelteKit frontend
against a real Rust backend with rate limits and quotas disabled.
Phases 1, 2, and 3-mobile-gestures are landed:
- Phase 1 — happy-path coverage of every documented user journey, plus a smoke matrix across nine browser/UA profiles to catch engine-level divergences.
- Phase 2 — adversarial inputs (XSS, SQL-injection, JWT forgery, MIME spoofing, oversize, brute-force) and browser chaos (storage purge, offline/slow-3G, multi-tab, clock skew, no-JS, quota exhaustion).
- Phase 3 (gestures only) — touch-target audit, safe-area structural
check, long-press → ContextSheet, double-tap → like, viewport reflow,
plus
test.fixmestubs for planned gestures (lightbox swipe, swipe-down dismiss, pull-to-refresh).
Phase 3 real-device compat (Android emulator + Samsung Internet via
connectOverCDP, BrowserStack), visual regression, and a11y audits are
sketched in the Roadmap at the bottom.
Quickstart
cd e2e
npm install
npm run install:browsers # one-time: ~500 MB across chromium/firefox/webkit
# 1. Boot the test stack (rebuilds backend + frontend Docker images)
npm run stack:up
# 2. Wait ~20s for migrations + warmup, then run tests
npm run test:e2e # full Phase 1 suite on chromium-desktop
npm run test:e2e:smoke # cross-UA smoke matrix (~9 projects × 1 test)
npm run test:e2e:ui # interactive Playwright UI mode
# 3. After: tear the stack down (deletes volumes)
npm run stack:down
The CI workflow at .github/workflows/e2e.yml runs both jobs on every PR.
What's tested
Every spec covers a journey from docs/USER_JOURNEYS.md
or a security/chaos scenario. One folder per area:
| Folder | Phase | Journeys / Topic | Tests | Notes |
|---|---|---|---|---|
specs/01-auth/ |
1 | §1, §2, §3, §11, §15 | 13 | Join, recover, PIN lockout, admin login, leave event. |
specs/02-upload/ |
1 | §5, §6, §18 | 5 | Gallery picker, multi-file, rate-limit, admin toggle. |
specs/03-feed/ |
1 | §7, §8, §17 | 5 | Like/comment SSE, filter chips, SSE reconnect. |
specs/04-host/ |
1 | §9 | 5 | Event lock, ban/unban, role change. |
specs/05-admin/ |
1 | §11, §16 | 11 | Config validation, foundational auth guards, stats. |
specs/06-export/ |
1 | §12 | 3 | Status, release, download stub. |
specs/__smoke/ |
1 | (matrix) | 1 × 9 UAs | @smoke-tagged happy-path on every UA project. |
specs/07-adversarial/ |
2 | Input attacks, file upload boundaries, JWT forgery, brute-force, deep authorization, small DDoS | ~40 | See breakdown below. |
specs/08-browser-chaos/ |
2 | Storage purge, IndexedDB, offline/slow-3G, multi-tab, no-JS, clock skew, quota | ~20 | See breakdown below. |
specs/09-mobile/ |
3 | Touch-target audit, safe-area, long-press, double-tap, viewport reflow, fixme stubs | 23 | Runs only on chromium-mobile (Pixel 7 viewport). See below. |
Phase 2 — adversarial (specs/07-adversarial/)
xss-injection.spec.ts— 13 tests. Six XSS payloads × display-name path- four SQLi patterns + length/encoding edge cases (NUL byte, RTL override,
caption overflow). Asserts
window.__xssFirednever gets set and nodialogevent fires.
- four SQLi patterns + length/encoding edge cases (NUL byte, RTL override,
caption overflow). Asserts
ui-rendering.spec.ts— 2 tests. Belt-and-braces: even when a script- payload sits in localStorage as the user's display name, rendering through/accountkeeps it as text.file-upload-attacks.spec.ts— 9 tests. ELF body claimed as JPEG, oversize image vsmax_image_size_mb, zero-byte, missing file field, path-traversal filename, NUL filename,application/*declared category bypass, SVG-with-script.auth-tampering.spec.ts— 8 tests.alg:noneforging admin role, signature tamper, payload tamper with original signature, logged-out session reuse, header withoutBearer, missing Authorization, PIN brute-force lockout, admin password brute-force (documented finding — no lockout today, bcrypt cost is the only defense).authorization-deep.spec.ts— 6 tests. Cross-user comment delete, banned user across like/comment/feed-read, host→admin escalation attempts.ddos.spec.ts— 4 small-scale abuse tests. 20 parallel /join, 10 MB comment body, 10 concurrent SSE streams, malformed JSON.
Phase 2 — browser chaos (specs/08-browser-chaos/)
storage-purge.spec.ts— 5 tests.localStorage.clear()mid-session, cookies cleared (JWT in localStorage still works), sessionStorage cleared, admin force-relogin, PIN intentionally survives clearAuth.indexeddb.spec.ts— 2 tests. Drop all IDB databases mid-session; stub IDB to undefined before navigation.offline-network.spec.ts— 4 tests.setOffline(true)→ reconnect, slow-3G viapage.routedelay, intermittent 503s, 429 from server (no infinite retry storm).multi-tab.spec.ts— 3 tests. Same user two tabs, two users two contexts (storage isolated), logout in tab A doesn't sync to tab B (documented gap).environment.spec.ts— 5 tests. JS disabled, localStorage quota exhausted, hostile CSS hiding nav, clock skew ±1h / -2d.
Pending tests covering features that need a Node-side multipart upload helper
are marked test.fixme and will activate when that helper lands.
Browser & UA matrix
| Project | Engine | UA / Device | Why |
|---|---|---|---|
chromium-desktop |
Chromium | Desktop Chrome | Baseline. Full suite runs here. |
chromium-pixel7 |
Chromium | Pixel 7 device descriptor | Chrome Android. |
chromium-galaxy-s22 |
Chromium | Galaxy viewport + Samsung phone UA | Chrome on Samsung hardware. |
samsung-internet |
Chromium | Galaxy viewport + SamsungBrowser UA | Tier-A Samsung Internet baseline. |
edge-android |
Chromium | Pixel viewport + EdgA UA | Edge Mobile (Blink-based). |
chrome-ios |
Chromium | iPhone viewport + CriOS UA | Chrome iOS (actually WebKit, but UA differs). |
webkit-iphone |
WebKit | iPhone 14 Pro | Real iOS Safari engine. |
firefox-android |
Firefox | Pixel viewport + Firefox Android UA | Gecko engine. |
firefox-desktop |
Firefox | Desktop Firefox | FF-specific quirks. |
Only the @smoke happy-path runs across all projects (controlled by
grep in playwright.config.ts). The full Phase 1 suite is
chromium-desktop-only by default to keep CI under 15 min.
Samsung Internet — three escalation tiers
Samsung Internet ships on every Galaxy phone (~5% of mobile traffic in DE). It's Blink-based, so Tier-A catches ~90% of regressions. Real Samsung divergences (Smart Switch save-data mode, dark-mode injection, custom autoplay, in-browser ad blocking) are only reproducible at Tier B+:
- Tier A (this repo, free, in CI): Playwright Chromium with the
Samsung Internet user-agent + Galaxy viewport. See the
samsung-internetproject inplaywright.config.ts. - Tier B (free, manual, future): Android Studio emulator on Linux →
install Samsung Internet APK → enable
--remote-debugging-port=9222→chromium.connectOverCDP('http://localhost:9222'). Setup docs live indocs/samsung-emulator.md(to be written). - Tier C (paid, optional): BrowserStack or LambdaTest cloud devices. Real Galaxy S22/S23 hardware via Playwright's cloud integration.
Test isolation
Every test runs against a freshly truncated database:
global-setup.tswaits for/health, logs in admin, and disables every rate-limit and quota toggle viaPATCH /admin/config.- The auto-fixture
truncateinfixtures/test.tscallsPOST /api/v1/admin/__truncatebefore every test. - The truncate endpoint is only registered when the backend is started
with
EVENTSNAP_TEST_MODE=1(seebackend/src/main.rsandbackend/src/handlers/test_admin.rs). Production builds return 404.
Single-worker by design (workers: 1 in the config). Per-worker isolated
DBs are a Phase-2+ change.
Architecture
e2e/
├── docker-compose.test.yml # Isolated test stack: db :55432, caddy :3101
├── Caddyfile.test # Proxies /api/* /media/* /health to backend
├── playwright.config.ts # UA matrix + smoke grep
├── global-setup.ts # admin login, rate-limit disable
├── global-teardown.ts # (no-op; use `npm run stack:down`)
├── fixtures/
│ ├── api-client.ts # Typed wrapper over /api/v1/*
│ ├── db.ts # Direct Postgres escape hatch (locked-PIN, etc.)
│ ├── test.ts # Central test.extend (guest, host, signIn fixtures)
│ └── media/ # sample.jpg, sample.mp4, not-an-image.jpg
├── helpers/
│ ├── sse-listener.ts # Async SSE iterator with waitForEvent()
│ ├── storage-helpers.ts # localStorage/sessionStorage helpers
│ └── fake-media.ts # Camera permissions (Chromium only)
├── page-objects/
│ ├── join-page.ts # /join
│ ├── recover-page.ts # /recover
│ ├── admin-login-page.ts # /admin/login
│ ├── feed-page.ts # /feed + bottom nav
│ ├── upload-sheet.ts # UploadSheet.svelte + /upload
│ ├── lightbox.ts # LightboxModal.svelte
│ ├── account-page.ts # /account
│ ├── host-dashboard.ts # /host
│ ├── admin-dashboard.ts # /admin
│ └── export-page.ts # /export
└── specs/
├── __smoke/ # @smoke cross-UA matrix (1 spec)
├── 01-auth/
├── 02-upload/
├── 03-feed/
├── 04-host/
├── 05-admin/
└── 06-export/
Debugging a failure
npm run test:e2e:ui— interactive UI with time-travel and selector probe.npm run test:e2e:headed— watch the browser run live.npm run test:e2e:debug— Playwright inspector with breakpoints.npm run stack:logs— tail backend + Postgres logs during a failure.playwright-report/index.html— opens the HTML report (auto-generated on every run).- Trace files (
test-results/**/trace.zip) drag-and-drop intohttps://trace.playwright.dev.
Conventions
- One assertion per
expect. Bundling multiple expects in one statement loses the line-level failure context. - Wait on data, not time. Use
expect.pollfor DB checks; neverwaitForTimeoutin production specs. @smoketag on each suite's happiest path so the matrix run stays under 2 min.test.fixmefor features that need infrastructure not yet built (Node-side multipart upload helper, real video fixtures, etc.). Fixme tests don't fail the suite but show up in the report.- Page objects own selectors. Specs never use raw locators.
- German text in assertions is fine — it's not going to change frequently. When it does, the page object is the only file to update.
Roadmap
Phase 2 — Adversarial & browser chaos ✅ landed
See the What's tested table above and the per-file breakdown. Known findings surfaced (documented in tests, not silent failures):
/admin/loginhas no rate-limit or lockout — bcrypt cost is the only defense.localStorage'storage' event is not listened for, so logout in tab A doesn't synchronously sign out tab B (the next 401 from any API call clears it).- SVG uploads currently pass the magic-byte check (depends on
infer's detection coverage) — consider addingX-Content-Type-Options: nosniff- CSP on
/media/*if SVGs are ever expected as user content.
- CSP on
Phase 3 — Mobile gestures (specs/09-mobile/) ✅ landed
Runs only on the chromium-mobile project (Pixel 7 device descriptor with
hasTouch and isMobile). The chromium-desktop project explicitly
ignores this folder via testIgnore in playwright.config.ts.
touch-targets.spec.ts— 4 tests. Audits ≥ 44×44 px on bottom nav, FAB, join submit, admin-login submit, PIN-modal buttons. Usesexpect.softso a single failure surfaces the actual bounding-box dimensions instead of stopping the suite.safe-area.spec.ts— 4 tests. Assertsenv(safe-area-inset-bottom)is present in the inline style of every bottom-anchored UI element (bottom nav, UploadSheet, ContextSheet), and that the nav stays flush with the viewport bottom on a no-notch emulated device.gestures-longpress.spec.ts— 3 tests. A 600 ms hold on a FeedListCard opens the ContextSheet; a 200 ms tap does not; the click-suppression logic prevents the lightbox from also opening at pointer-up. Driven viapage.mouse.down/upbecause thelongpressaction listens for pointer events (mouse/touch/pen unified).gestures-doubletap.spec.ts— 2 tests. Double-tap on a feed card image button records a like; double-tap inside the lightbox triggers the heart-burst animation and records a like. Assertions read the like count back via/api/v1/feedso they don't couple to specific badge markup.viewport-reflow.spec.ts— 5 tests. Portrait, landscape, narrow (320×568), phablet (480×1024) — each asserts the bottom nav is visible, the FAB stays roughly centered, and there's no horizontal overflow on<html>. Plus a rotation test that confirms auth survives a viewport resize.planned-gestures.spec.ts— 5test.fixmestubs documenting the contracts for gestures from journey §17 that aren't shipped yet (lightbox swipe L/R, swipe-down to dismiss UploadSheet, pull-to-refresh, long-press on a comment). Fliptest.fixmetotestwhen wiring each gesture.
Driving gestures: the helpers/touch.ts module
longPress(page, locator, durationMs)— holds the pointer down for the duration. Default 600 ms beats the action's 500 ms threshold.doubleTap(page, locator)— twomouse.down/uppairs within thedoubletapaction's 300 ms window.swipe(page, from, to, steps)— gradual mouse-driven move (used by the fixme stubs once swipe gestures land).inlineStyle(locator)/computedStyle(locator, prop)— read rawstyleattributes (whereenv(...)strings live) and computed values.
Phase 3 — Real-device compat & visual / a11y (not landed)
- Long-press own/other post, swipe lightbox L/R, swipe-down dismiss, pull-to-refresh, double-tap like.
- Safe-area inset visual diff on iPhone notch.
- Touch-target ≥ 44 px audit.
- Tier B Samsung Internet via
connectOverCDPon Android Studio emulator. - Tier C BrowserStack integration (paid, optional).
@axe-core/playwrightaccessibility audits.- Visual regression with screenshot diffs.
Out of scope (handed to other tools)
- Load testing → k6 / Vegeta.
- API contract testing → backend
cargo testintegration tests. - Static asset auditing → Lighthouse CI.