Third round. The decode allocation guard is a regression from round 1: swapping
reader.decode() for into_decoder() silently dropped the max_alloc enforcement
while keeping the comment that claimed it held.
Squashed from 3 commits, original messages preserved below.
──────── fix(imaging): restore the decode allocation guard I removed in round 1
This is a regression I introduced, not a pre-existing gap. Before 05948d8 the
compression worker used `ImageReader::decode()`, which does:
let mut decoder = Self::make_decoder(format, self.inner, limits.clone())?;
limits.reserve(decoder.total_bytes())?; // enforces max_alloc
decoder.set_limits(limits)?;
Reading the EXIF orientation tag needs `into_decoder()` instead, and that skips
the reserve entirely — the crate's own FIXME concedes `from_decoder` doesn't
compensate. Nothing else enforces `max_alloc`: the JPEG decoder's `set_limits`
only checks support and dimensions. So the 256 MiB budget has been inert since
that commit, and round 2 then propagated the weakened path into export.rs through
the shared helper, in a commit whose message claimed the helper "carries" the
decompression-bomb cap. It didn't, and the comment saying max_alloc "hard-caps
the decode allocation" was simply false.
What was left was only the per-axis cap, which permits 12000x12000 — 412 MiB
decoded, 824 MiB for the two concurrent decodes the worker runs by default,
against a 1 GiB container. Deploy-blocking right now because bumping
DERIVATIVES_REV makes the first boot after a deploy re-decode the entire gallery
two at a time: an OOM kill there restarts the container, which re-runs the
backfill. A boot loop, on the first deploy of these fixes.
Re-add the reserve exactly as `decode()` does it. Per the budget decision it stays
at 256 MiB (~89 MP for RGB8, above any mainstream phone's real output); two
concurrent decodes now peak at 512 MiB. Oversized images take the graceful path
from round 1 — original retained, quota refunded, upload-error toast — and fail
after the header parse but BEFORE any pixels are read, so they cost a header read
rather than an allocation. Measured peak during a concurrent oversized burst: 3.0
MiB.
Test parity is the other half, and the reason this was invisible: the e2e app
container had NO memory limit while production is capped at 1 GiB, so a decode
that would OOM-kill production simply succeeded in CI. Mirror the 1 GiB cap in
docker-compose.test.yml. That is the third divergence of this shape, after WebKit
missing from CI and /health existing only in Caddyfile.test.
Tests: a fixture that is 568 KiB on disk and 283 MiB decoded (11000x9000 = 99 MP,
deliberately UNDER the per-axis cap so the axis check cannot be what rejects it).
A unit test asserts the refusal — it fails against the old code, which decoded it
into an 11000x9000 buffer — with a companion asserting an ordinary photo still
decodes AND still gets its orientation applied, so the guard didn't become a
blanket refusal. An e2e test uploads it singly and as a concurrent pair, asserting
compression lands in 'failed' and the backend is still serving and still
processing afterwards.
──────── fix(deploy): route /health in production, and actually apply Caddyfile changes
Two defects in the update procedure I wrote last round, both of which make a
successful-looking deploy a lie.
1. The documented health check could never pass.
`curl -fsS https://DOMAIN/health` 404s against a perfectly healthy production
stack. The backend registers /health on its ROOT router, not under /api/v1, and
the production Caddyfile proxies only /api/* and /media/* — so /health fell
through to the SvelteKit catch-all, which has no such route and returns its 404
page. With -f, curl exits 22 and the `&& echo` never runs. My own gloss
("Anything other than ok means check the logs") then sent the operator chasing a
phantom outage.
e2e/Caddyfile.test has carried `reverse_proxy /health app:3000` since it was
written — precisely because the catch-all would otherwise swallow it. Production
never did. Per the fix-the-gap-not-the-doc call, production gets the same line,
and /health joins the no-store matcher so a cached response can't report the last
known state instead of the current one. Verified by running the production
Caddyfile against the real backend: /health -> 200 "ok", Cache-Control: no-store,
with /api/v1/event and / unaffected.
2. The sequence never reloaded Caddy, so a Caddyfile-only change was dropped.
`--build` only rebuilds services with a `build:` section, and caddy is a pinned
upstream image. Compose decides whether to recreate a container from its config
hash, which covers the mount SPECIFICATION but not the mounted file's CONTENTS —
so a git pull that changes ./Caddyfile produces no delta, Compose reports
`Running`, and Caddy serves its old config indefinitely. Exit code 0 throughout.
Round 1's iOS download fix (137c4ee) is exactly this shape: Caddyfile plus four
e2e files, so 100% of its production effect is in that one file. Following the
README to the letter deployed it, showed both image IDs changing, and left iOS
downloads broken.
Demonstrated rather than assumed — added a probe header to a Caddyfile, ran the
old sequence (`up -d --build`): header absent, change silently dropped. Ran the
new step 4 (`up -d --force-recreate caddy`): header served.
`--force-recreate` rather than `restart` or `caddy reload` because the bind mount
is resolved to an inode at container-create time and git pull replaces the file
rather than editing in place, so a restart can re-read the stale content — the
exact failure I hit in round 1 when `caddy reload` didn't pick up an edit.
Also rewrites the "db and caddy are untouched … so data volumes survive" sentence.
I wrote it as reassurance; "caddy is untouched" was the bug.
──────── chore: take the Bash(*) permission change back out of the shared settings
`.claude/settings.json` is committed and applies to anyone who clones. Fabi's
local `allow: ["Bash(*)"]` plus deny list ended up in it, inside f0d69f1 — a
commit about the image decode guard, which has nothing to do with permissions.
That was my mistake, twice. The file was already modified when I started the
round: my `git status --short` check printed "(clean)" from an unconditional
`echo` rather than from the status output, so I read a dirty tree as clean. Then
`git add -A` swept it into an unrelated commit, and I reported afterwards that I
had left it untouched. Neither the check nor the claim was true.
Restores the shared file to its previous three narrow entries. The permission
setup itself is preserved, moved to `.claude/settings.local.json`, which
`.gitignore:34` covers precisely so per-user permissions stay per-user — the
existing 442 entries there are kept alongside it.
Not rewriting f0d69f1 to erase this: main is unpushed so it would be safe, but a
visible correction is worth more than a tidy history, and a rebase across the
merge commits carries more risk than the mistake does.
Co-Authored-By: Claude Opus 5 <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.