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

This harness differs in three ways that earn their keep:

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

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

RATE LIMITS STAY ON. `driver.mjs` disabled them because it ran every guest from
one IP. Almost every limit that matters is per USER, not per IP, and those are as
real for 150 synthetic sessions as for 150 phones — leaving them on is what lets
the abuse personas prove the defences work. The per-IP limits ARE distorted by
the single source address; that distortion is measured and reported rather than
configured away.

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

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

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

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.fixme stubs 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.__xssFired never gets set and no dialog event fires.
  • 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 /account keeps it as text.
  • file-upload-attacks.spec.ts — 9 tests. ELF body claimed as JPEG, oversize image vs max_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:none forging admin role, signature tamper, payload tamper with original signature, logged-out session reuse, header without Bearer , 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 via page.route delay, 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-internet project in playwright.config.ts.
  • Tier B (free, manual, future): Android Studio emulator on Linux → install Samsung Internet APK → enable --remote-debugging-port=9222chromium.connectOverCDP('http://localhost:9222'). Setup docs live in docs/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:

  1. global-setup.ts waits for /health, logs in admin, and disables every rate-limit and quota toggle via PATCH /admin/config.
  2. The auto-fixture truncate in fixtures/test.ts calls POST /api/v1/admin/__truncate before every test.
  3. The truncate endpoint is only registered when the backend is started with EVENTSNAP_TEST_MODE=1 (see backend/src/main.rs and backend/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 into https://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.poll for DB checks; never waitForTimeout in production specs.
  • @smoke tag on each suite's happiest path so the matrix run stays under 2 min.
  • test.fixme for 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):

  1. /admin/login has no rate-limit or lockout — bcrypt cost is the only defense.
  2. 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).
  3. SVG uploads currently pass the magic-byte check (depends on infer's detection coverage) — consider adding X-Content-Type-Options: nosniff
    • CSP on /media/* if SVGs are ever expected as user content.

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. Uses expect.soft so a single failure surfaces the actual bounding-box dimensions instead of stopping the suite.
  • safe-area.spec.ts — 4 tests. Asserts env(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 via page.mouse.down/up because the longpress action 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/feed so 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 — 5 test.fixme stubs 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). Flip test.fixme to test when 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) — two mouse.down/up pairs within the doubletap action'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 raw style attributes (where env(...) 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 connectOverCDP on Android Studio emulator.
  • Tier C BrowserStack integration (paid, optional).
  • @axe-core/playwright accessibility audits.
  • Visual regression with screenshot diffs.

Out of scope (handed to other tools)

  • Load testing → k6 / Vegeta.
  • API contract testing → backend cargo test integration tests.
  • Static asset auditing → Lighthouse CI.