Compare commits
39 Commits
641174717c
...
5546fb82e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5546fb82e6 | ||
|
|
e2b7e54af9 | ||
|
|
15d338eeb8 | ||
|
|
461b1eaf65 | ||
|
|
460258c451 | ||
|
|
bbdfae09a0 | ||
|
|
f8cba95e49 | ||
|
|
4d14df18d0 | ||
|
|
ee554e7f38 | ||
|
|
0fa40ddf80 | ||
|
|
c48d43f5b3 | ||
|
|
db7c4459d7 | ||
|
|
dd7b05415e | ||
|
|
d2ad560df2 | ||
|
|
d452afb00e | ||
|
|
3f74c65787 | ||
|
|
02971f3186 | ||
|
|
e5201a9889 | ||
|
|
c229b560d8 | ||
|
|
af997a84dd | ||
|
|
2bef6e19ef | ||
|
|
db88230221 | ||
|
|
bf68bc08f4 | ||
|
|
38e34fddf1 | ||
|
|
3c683247c0 | ||
|
|
0447a6ad0e | ||
|
|
9666d74a46 | ||
|
|
5affef47cf | ||
|
|
8e906d7866 | ||
|
|
1148c2e906 | ||
|
|
9f3712894d | ||
|
|
c197b2c025 | ||
|
|
d643256f36 | ||
|
|
99f79e2898 | ||
|
|
df275bbefa | ||
|
|
768e712a26 | ||
|
|
811c724685 | ||
|
|
c647ddfa6b | ||
|
|
36fe59caa5 |
73
.github/workflows/audit.yml
vendored
Normal file
73
.github/workflows/audit.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
# Dependency advisory scan.
|
||||
#
|
||||
# Lives in .github/workflows/ because Gitea Actions scans BOTH .gitea/workflows and
|
||||
# .github/workflows, and e2e.yml already proves this instance resolves `actions/*` from GitHub
|
||||
# (DEFAULT_ACTIONS_URL). Keeping one directory avoids a split where half the CI is invisible
|
||||
# depending on which convention you look under.
|
||||
#
|
||||
# Unlike the GitHub-hosted runners this workflow does NOT assume a preinstalled Rust toolchain —
|
||||
# the common Gitea runner images (gitea/runner-images, catthehacker/ubuntu) ship Node and Docker
|
||||
# but not cargo. So we install it explicitly instead of relying on the ambient environment.
|
||||
name: Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# New advisories land against unchanged code, so a push-only trigger would never see them.
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
jobs:
|
||||
cargo-audit:
|
||||
name: cargo audit (backend)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: |
|
||||
if ! command -v cargo > /dev/null; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
fi
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/bin
|
||||
key: cargo-audit-${{ hashFiles('backend/Cargo.lock') }}
|
||||
restore-keys: cargo-audit-
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit --locked || true
|
||||
|
||||
# `rsa` 0.9 (RUSTSEC-2023-0071, "Marvin") is a transitive dep of the SQLx MySQL driver that
|
||||
# this app never exercises: auth is HS256 + bcrypt, and the only database is Postgres. There
|
||||
# is no patched release, so failing the build on it would mean a permanently red pipeline
|
||||
# that everyone learns to ignore — which is worse than not scanning at all. Ignore it
|
||||
# SPECIFICALLY, so that any OTHER advisory still fails the job.
|
||||
- name: Audit
|
||||
working-directory: ./backend
|
||||
run: cargo audit --deny warnings --ignore RUSTSEC-2023-0071
|
||||
|
||||
npm-audit:
|
||||
name: npm audit (frontend)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install deps
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
# Production dependencies only: a dev-only advisory (build tooling, test runners) can't be
|
||||
# reached by a guest at the party, and gating merges on it just trains people to skip the gate.
|
||||
- name: Audit
|
||||
working-directory: ./frontend
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
129
.github/workflows/checks.yml
vendored
Normal file
129
.github/workflows/checks.yml
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
# The checks that were only ever running on a laptop.
|
||||
#
|
||||
# Before this file, CI ran Playwright (chromium-desktop) and the dependency audit — and nothing
|
||||
# else. `cargo test` (40 tests), the frontend vitest suite (5 files, including the offline
|
||||
# upload-queue and auth-token logic), svelte-check, and the e2e typecheck were all green on a
|
||||
# developer's machine and gated NOTHING. A check that only ever runs locally is not running.
|
||||
#
|
||||
# In .github/workflows/ because Gitea Actions scans it too (see audit.yml). Rust is installed
|
||||
# explicitly: the common Gitea runner images ship Node and Docker, not cargo.
|
||||
name: Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend — cargo test + clippy + fmt
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: |
|
||||
if ! command -v cargo > /dev/null; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
fi
|
||||
rustup component add clippy rustfmt
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-${{ hashFiles('backend/Cargo.lock') }}
|
||||
restore-keys: cargo-
|
||||
|
||||
# SQLx runs its queries against a live database at TEST time (see backend/tests/), so the
|
||||
# DB-backed tests need one. The pure unit tests don't care, but starting it unconditionally
|
||||
# keeps the job simple and honest about what it covers.
|
||||
- name: Start Postgres
|
||||
run: |
|
||||
docker run -d --name ci-pg -p 5432:5432 \
|
||||
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=eventsnap_ci \
|
||||
postgres:16-alpine
|
||||
for _ in $(seq 1 30); do
|
||||
docker exec ci-pg pg_isready -U postgres > /dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Test
|
||||
working-directory: ./backend
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/eventsnap_ci
|
||||
# `#[sqlx::test]` creates a fresh database per test and opens its own pool; run at unbounded
|
||||
# parallelism against a default `max_connections=100` Postgres, a full suite can exhaust the
|
||||
# server's connection slots and fail with PoolTimedOut — a pure infra flake, not a real
|
||||
# failure. Cap the concurrency so the gate stays trustworthy on a loaded runner.
|
||||
run: cargo test --all-features -- --test-threads=8
|
||||
|
||||
- name: Clippy
|
||||
working-directory: ./backend
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
- name: Format
|
||||
working-directory: ./backend
|
||||
run: cargo fmt --check
|
||||
|
||||
frontend:
|
||||
name: Frontend — vitest + svelte-check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend/package-lock.json'
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ./frontend
|
||||
run: npm ci || npm install
|
||||
|
||||
- name: Unit tests
|
||||
working-directory: ./frontend
|
||||
run: npm run test:unit
|
||||
|
||||
- name: svelte-check
|
||||
working-directory: ./frontend
|
||||
run: npx svelte-check --threshold error
|
||||
|
||||
- name: ESLint
|
||||
working-directory: ./frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Prettier
|
||||
working-directory: ./frontend
|
||||
run: npm run format:check
|
||||
|
||||
e2e-typecheck:
|
||||
name: E2E — typecheck + lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install deps
|
||||
working-directory: ./e2e
|
||||
run: npm install
|
||||
# Playwright TRANSPILES specs without typechecking them, so a type error in a spec is
|
||||
# invisible until the assertion it guards silently does the wrong thing at runtime.
|
||||
- name: tsc --noEmit
|
||||
working-directory: ./e2e
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: ESLint
|
||||
working-directory: ./e2e
|
||||
run: npm run lint
|
||||
|
||||
- name: Prettier
|
||||
working-directory: ./e2e
|
||||
run: npm run format:check
|
||||
8
.github/workflows/e2e.yml
vendored
8
.github/workflows/e2e.yml
vendored
@@ -46,6 +46,14 @@ jobs:
|
||||
working-directory: ./e2e
|
||||
run: npm run test:e2e -- --project=chromium-desktop
|
||||
|
||||
# 09-mobile is `testIgnore`d on chromium-desktop (it needs hasTouch + a phone viewport), so
|
||||
# running only that project left 22 tests — focus traps, touch targets, safe-area insets,
|
||||
# viewport reflow, upload-cancel — never executing in CI. On a phone-first event app, where
|
||||
# essentially every real guest is on a phone, that was the wrong half of the suite to skip.
|
||||
- name: Run E2E tests (mobile)
|
||||
working-directory: ./e2e
|
||||
run: npm run test:e2e -- --project=chromium-mobile
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
31
README.md
31
README.md
@@ -182,6 +182,28 @@ The `/media` volume holds originals, previews, thumbnails, exports, and DB backu
|
||||
|
||||
---
|
||||
|
||||
## Running the backend test suite
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# The DB-backed integration tests (backend/tests/) need a live Postgres. `#[sqlx::test]` creates a
|
||||
# throwaway database per test and runs backend/migrations/ into it — it does NOT touch this one's data.
|
||||
docker run -d --name eventsnap-test-pg -p 55433:5432 \
|
||||
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=eventsnap postgres:16-alpine
|
||||
|
||||
export DATABASE_URL=postgres://postgres:postgres@localhost:55433/eventsnap
|
||||
cargo test # 44 unit + 12 DB-backed
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
**`cargo test` requires `DATABASE_URL`** — without it the integration tests panic rather than skip.
|
||||
That is deliberate. The riskiest code in this repo is SQL (the export epoch state machine, the
|
||||
atomic quota increment, the `FOR SHARE` upload lock), and for a long time *not one line of it* was
|
||||
executed by `cargo test` — every backend test was a pure-function test, so the tests clustered
|
||||
tightly around the code that could not break and stopped exactly where it started to. Tests that
|
||||
silently skip when the database is absent recreate that hole; they were meant to be a gate.
|
||||
|
||||
## Running the E2E test suite
|
||||
|
||||
Playwright-based end-to-end tests live in [`e2e/`](e2e/). They spin up an isolated docker-compose stack (Postgres on `:55432`, Caddy on `:3101`) and exercise the SvelteKit frontend against the real Rust backend with rate limits disabled.
|
||||
@@ -198,7 +220,14 @@ npm run stack:down # tear it down
|
||||
|
||||
See [`e2e/README.md`](e2e/README.md) for the full UA matrix, Samsung Internet escalation tiers, and the Phase 2/3 roadmap.
|
||||
|
||||
CI runs this on every PR — see [`.github/workflows/e2e.yml`](.github/workflows/e2e.yml).
|
||||
CI runs this on every PR — see [`.github/workflows/e2e.yml`](.github/workflows/e2e.yml) (desktop **and**
|
||||
mobile projects), plus [`checks.yml`](.github/workflows/checks.yml) for `cargo test`/clippy, the frontend
|
||||
unit tests, svelte-check and the e2e typecheck, and [`audit.yml`](.github/workflows/audit.yml) for
|
||||
dependency advisories.
|
||||
|
||||
**Playwright runs with `retries: 0`, including in CI.** This repo's real bugs are races, and from the
|
||||
outside a race is indistinguishable from a flake — so a retry silently resolves that ambiguity in
|
||||
favour of "flake" every time. A flake here is a bug report; treat it as one.
|
||||
|
||||
---
|
||||
|
||||
|
||||
2
backend/migrations/012_export_release_seq.down.sql
Normal file
2
backend/migrations/012_export_release_seq.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE export_job
|
||||
DROP COLUMN IF EXISTS release_seq;
|
||||
11
backend/migrations/012_export_release_seq.up.sql
Normal file
11
backend/migrations/012_export_release_seq.up.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- H1: export generation guard. A reopen (which clears `export_released_at`) followed by a
|
||||
-- re-release could spawn a fresh export worker while a worker from the PRIOR release was
|
||||
-- still streaming a now-stale snapshot to `Gallery.zip`. The stale worker's finalize then
|
||||
-- re-set `export_*_ready = TRUE` on an archive that predated the reopen — a silent,
|
||||
-- permanent data loss (new uploads missing from a "ready" keepsake).
|
||||
--
|
||||
-- `release_seq` is a per-(event,type) generation counter bumped on every (re)release.
|
||||
-- A worker captures the seq it claimed and only finalizes / flips the ready flag while
|
||||
-- that seq is still current; a superseded worker discards its output instead.
|
||||
ALTER TABLE export_job
|
||||
ADD COLUMN release_seq BIGINT NOT NULL DEFAULT 0;
|
||||
2
backend/migrations/013_uploads_hidden_at.down.sql
Normal file
2
backend/migrations/013_uploads_hidden_at.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user"
|
||||
DROP COLUMN IF EXISTS uploads_hidden_at;
|
||||
12
backend/migrations/013_uploads_hidden_at.up.sql
Normal file
12
backend/migrations/013_uploads_hidden_at.up.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Ban-replay on reconnect. A ban is not a soft-delete (it sets `uploads_hidden`/`is_banned`,
|
||||
-- never `upload.deleted_at`), and live eviction rode only on the ephemeral `user-hidden` SSE
|
||||
-- broadcast — which has no reconnect-replay. So a client (especially the unattended diashow
|
||||
-- projector) that missed that broadcast kept cycling the banned user's already-loaded slides.
|
||||
--
|
||||
-- `uploads_hidden_at` stamps WHEN a user's uploads became hidden, so the reconnect delta can
|
||||
-- return the set of users hidden since the client's cursor and the client can evict them.
|
||||
ALTER TABLE "user"
|
||||
ADD COLUMN uploads_hidden_at TIMESTAMPTZ;
|
||||
|
||||
-- Backfill already-hidden users so a reconnect right after this migration still evicts them.
|
||||
UPDATE "user" SET uploads_hidden_at = NOW() WHERE uploads_hidden = TRUE;
|
||||
28
backend/migrations/014_export_epoch.down.sql
Normal file
28
backend/migrations/014_export_epoch.down.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Restore the stored ready flags and the per-job counter.
|
||||
DROP VIEW IF EXISTS export_current;
|
||||
|
||||
ALTER TABLE event ADD COLUMN export_zip_ready BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE event ADD COLUMN export_html_ready BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Re-derive the flags from what the epoch model considers downloadable, so the old code sees
|
||||
-- exactly the state it would have written itself.
|
||||
UPDATE event e
|
||||
SET export_zip_ready = EXISTS (
|
||||
SELECT 1 FROM export_job j
|
||||
WHERE j.event_id = e.id AND j.type = 'zip'
|
||||
AND j.status = 'done' AND j.epoch = e.export_epoch
|
||||
),
|
||||
export_html_ready = EXISTS (
|
||||
SELECT 1 FROM export_job j
|
||||
WHERE j.event_id = e.id AND j.type = 'html'
|
||||
AND j.status = 'done' AND j.epoch = e.export_epoch
|
||||
)
|
||||
WHERE e.export_released_at IS NOT NULL;
|
||||
|
||||
ALTER TABLE export_job RENAME COLUMN epoch TO release_seq;
|
||||
|
||||
-- The old code treats release_seq as a non-negative per-job counter; the -1 retirement sentinel
|
||||
-- has no meaning there, so floor it back to 0.
|
||||
UPDATE export_job SET release_seq = 0 WHERE release_seq < 0;
|
||||
|
||||
ALTER TABLE event DROP COLUMN export_epoch;
|
||||
86
backend/migrations/014_export_epoch.up.sql
Normal file
86
backend/migrations/014_export_epoch.up.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- Export generation, unified.
|
||||
--
|
||||
-- ⚠ ROLLBACK RUNBOOK. This is the repo's first DESTRUCTIVE migration (it DROPs two columns). The
|
||||
-- previous image will NOT boot against this schema: sqlx runs migrations before serving and errors
|
||||
-- with VersionMissing on an unknown version, so you get a crash loop, not a degraded service.
|
||||
-- To roll back to the previous release you must run 014_export_epoch.down.sql BY HAND FIRST, then
|
||||
-- deploy the old image. The down migration re-derives the old ready flags from the epoch state, so
|
||||
-- no keepsake is lost.
|
||||
--
|
||||
-- Before this migration, "which generation of the keepsake is current?" had no single answer.
|
||||
-- It was assembled at runtime from three separately-written pieces of state across two tables:
|
||||
-- * event.export_released_at — a release marker with NO identity (you cannot ask *which* release)
|
||||
-- * export_job.release_seq — a per-row counter, in a different table, bumped in a different statement
|
||||
-- * event.export_{zip,html}_ready — a CACHED COPY of the derivation of the other two
|
||||
-- Keeping those three in agreement took ~10 hand-written guards, and every guard was a repair of the
|
||||
-- same missing invariant. Three consecutive review rounds each found another path that slipped through.
|
||||
--
|
||||
-- This replaces all of it with ONE authority:
|
||||
--
|
||||
-- event.export_epoch — a monotonic counter bumped in the SAME UPDATE as any change to
|
||||
-- export_released_at (release and reopen are its only writers).
|
||||
-- export_job.epoch — a COPY of event.export_epoch taken when the job was enqueued.
|
||||
-- NEVER independently incremented.
|
||||
--
|
||||
-- The single invariant, which replaces the entire proof:
|
||||
--
|
||||
-- An export is downloadable IFF
|
||||
-- event.export_released_at IS NOT NULL
|
||||
-- AND export_job.epoch = event.export_epoch
|
||||
-- AND export_job.status = 'done'
|
||||
--
|
||||
-- Readiness is therefore DERIVED, never stored — so it cannot drift, and no worker can set it.
|
||||
-- A worker holding a dead epoch is INERT BY CONSTRUCTION: anything it writes is invisible to every
|
||||
-- reader, with no guard involved. Losing a race can no longer corrupt state; it can only waste work.
|
||||
--
|
||||
-- Crucially, epoch equality is checked on the SAME ROW the worker updates (export_job), never via a
|
||||
-- cross-table EXISTS. That matters: under READ COMMITTED, when an UPDATE blocks on a row lock and the
|
||||
-- blocker commits, Postgres re-evaluates the WHERE against the *updated target row* but answers
|
||||
-- subqueries on OTHER tables from the statement's ORIGINAL snapshot. The old cross-row guard
|
||||
-- (`EXISTS (SELECT 1 FROM event WHERE ... export_released_at IS NOT NULL)`) was unsound for exactly
|
||||
-- that reason — it could admit a claim against an already-reopened event while RETURNING the
|
||||
-- post-bump seq. A same-row `epoch = $n` predicate is re-evaluated correctly by EPQ.
|
||||
|
||||
ALTER TABLE event ADD COLUMN export_epoch BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- A currently-released event's live generation becomes epoch 1.
|
||||
UPDATE event SET export_epoch = 1 WHERE export_released_at IS NOT NULL;
|
||||
|
||||
-- The per-job counter becomes a plain copy of the event epoch it was enqueued for.
|
||||
ALTER TABLE export_job RENAME COLUMN release_seq TO epoch;
|
||||
|
||||
-- Carry the OLD notion of "downloadable" across exactly: a job the old model considered ready
|
||||
-- (released + done + its ready flag) adopts the event's live epoch. Everything else is retired to
|
||||
-- -1, which can never equal a non-negative event.export_epoch, so it is inert forever.
|
||||
UPDATE export_job j
|
||||
SET epoch = CASE
|
||||
WHEN e.export_released_at IS NOT NULL
|
||||
AND j.status = 'done'
|
||||
AND ((j.type = 'zip' AND e.export_zip_ready)
|
||||
OR (j.type = 'html' AND e.export_html_ready))
|
||||
THEN e.export_epoch
|
||||
ELSE -1
|
||||
END
|
||||
FROM event e
|
||||
WHERE e.id = j.event_id;
|
||||
|
||||
-- The duplicated derivation. Gone — along with the whole class of bugs where a stale worker
|
||||
-- resurrected it, or where it disagreed with the job row it was supposed to summarise.
|
||||
ALTER TABLE event DROP COLUMN export_zip_ready;
|
||||
ALTER TABLE event DROP COLUMN export_html_ready;
|
||||
|
||||
-- The ONE definition of "the current generation". Every reader goes through this, so readiness and
|
||||
-- the file path are resolved in a SINGLE read — closing the download-path TOCTOU where the ready
|
||||
-- flag was checked in one query and file_path fetched in another (a reopen between the two served a
|
||||
-- keepsake that should have 404'd).
|
||||
CREATE VIEW export_current AS
|
||||
SELECT j.event_id,
|
||||
j.type,
|
||||
j.status,
|
||||
j.progress_pct,
|
||||
j.file_path,
|
||||
j.epoch
|
||||
FROM export_job j
|
||||
JOIN event e ON e.id = j.event_id
|
||||
WHERE e.export_released_at IS NOT NULL -- implied by the epoch match; kept as an assertion
|
||||
AND j.epoch = e.export_epoch;
|
||||
195
backend/scripts/rehearse-014.sh
Executable file
195
backend/scripts/rehearse-014.sh
Executable file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Dress rehearsal for migration 014 (export_epoch) — the repo's first DESTRUCTIVE migration.
|
||||
#
|
||||
# 014 DROPs event.export_zip_ready / export_html_ready and rewrites export_job.release_seq into an
|
||||
# epoch. The dangerous part is not the DDL, it's the BACKFILL: it has to carry the old notion of
|
||||
# "downloadable" across exactly. Get it wrong and a released event's keepsake silently 404s for
|
||||
# every guest, on a dataset you cannot re-create.
|
||||
#
|
||||
# This script proves the backfill preserves downloadability, against a scratch copy of a REAL dump:
|
||||
#
|
||||
# ./rehearse-014.sh /path/to/prod-dump.sql # rehearse against production data
|
||||
# ./rehearse-014.sh # synthetic: build a pre-014 DB covering every case
|
||||
#
|
||||
# It asserts:
|
||||
# 1. up: {(event,type) downloadable BEFORE} == {(event,type) downloadable AFTER}
|
||||
# 2. down: the old ready flags come back identical (so a rollback is actually a rollback)
|
||||
# 3. up again: still identical (so a roll-forward after a rollback is safe)
|
||||
#
|
||||
# It NEVER touches your real database. Everything runs in a throwaway container.
|
||||
#
|
||||
# NOTE ON ROLLBACK (see the runbook header in 014_export_epoch.up.sql): sqlx runs migrations before
|
||||
# serving and errors with VersionMissing on an unknown version, so the PREVIOUS image will not boot
|
||||
# against the 014 schema — it crash-loops. Rolling back means running 014_export_epoch.down.sql BY
|
||||
# HAND FIRST, then deploying the old image. Assertion 2 is what makes that safe. Rehearse it before
|
||||
# you need it, not while the party is happening.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DUMP="${1:-}"
|
||||
MIG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../migrations" && pwd)"
|
||||
CONTAINER="eventsnap-rehearse-014"
|
||||
PGPASSWORD="rehearse"
|
||||
DB="eventsnap_rehearsal"
|
||||
|
||||
cleanup() { docker rm -f "$CONTAINER" > /dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
cleanup
|
||||
|
||||
echo "▸ Starting a throwaway postgres:16 (nothing here touches your real DB)…"
|
||||
docker run --rm -d --name "$CONTAINER" \
|
||||
-e POSTGRES_PASSWORD="$PGPASSWORD" -e POSTGRES_DB="$DB" \
|
||||
postgres:16-alpine > /dev/null
|
||||
|
||||
psql() { docker exec -i -e PGPASSWORD="$PGPASSWORD" "$CONTAINER" psql -U postgres -d "$DB" -v ON_ERROR_STOP=1 "$@"; }
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
if docker exec "$CONTAINER" pg_isready -U postgres > /dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ -n "$DUMP" ]]; then
|
||||
echo "▸ Restoring $DUMP …"
|
||||
psql -q < "$DUMP"
|
||||
else
|
||||
echo "▸ No dump given — building a synthetic pre-014 DB (migrations 001→013)…"
|
||||
for f in "$MIG_DIR"/0[0-1][0-9]_*.up.sql; do
|
||||
[[ "$(basename "$f")" == 014_* ]] && continue
|
||||
psql -q < "$f"
|
||||
done
|
||||
|
||||
# Every state the backfill has to classify. If 014 mishandles ANY of these, assertion 1 fails.
|
||||
# Every state the backfill has to classify. export_job is UNIQUE (event_id, type), so each event
|
||||
# has at most one job per type — the cases are distinguished by event, not by piling up rows.
|
||||
# A: released, both flags, both done → both stay downloadable
|
||||
# B: released, ZIP flag only → ZIP downloadable, HTML not (a half-built keepsake)
|
||||
# C: released, jobs done, NO flags → NOT downloadable (a superseded worker's finished job:
|
||||
# the exact state the old ready-flag model used to leak)
|
||||
# D: never released, jobs done → NOT downloadable (reopened, or built before release)
|
||||
# E: released, ZIP flag set but the job is FAILED/RUNNING → NOT downloadable (flag/job disagree,
|
||||
# which the old two-source model made representable at all)
|
||||
echo "▸ Seeding: released+both, zip-only, done-but-stale, unreleased, flag/job-disagreement…"
|
||||
psql -q <<'SQL'
|
||||
INSERT INTO event (id, slug, name, export_released_at, export_zip_ready, export_html_ready) VALUES
|
||||
('aaaaaaaa-0000-0000-0000-000000000001','ev-a','A', now(), TRUE, TRUE),
|
||||
('aaaaaaaa-0000-0000-0000-000000000002','ev-b','B', now(), TRUE, FALSE),
|
||||
('aaaaaaaa-0000-0000-0000-000000000003','ev-c','C', now(), FALSE, FALSE),
|
||||
('aaaaaaaa-0000-0000-0000-000000000004','ev-d','D', NULL, FALSE, FALSE),
|
||||
('aaaaaaaa-0000-0000-0000-000000000005','ev-e','E', now(), TRUE, TRUE);
|
||||
|
||||
INSERT INTO export_job (event_id, type, status, progress_pct, file_path, release_seq)
|
||||
SELECT e.id, t::export_type, 'done'::export_status, 100, '/x/' || e.slug || '.' || t, 0
|
||||
FROM event e CROSS JOIN (VALUES ('zip'),('html')) AS v(t);
|
||||
|
||||
-- E: the flags claim ready, the jobs say otherwise. Old model required BOTH, so neither is
|
||||
-- downloadable — the migration must not "trust the flag" and resurrect them.
|
||||
UPDATE export_job SET status = 'failed', file_path = NULL, progress_pct = 40
|
||||
WHERE event_id = 'aaaaaaaa-0000-0000-0000-000000000005' AND type = 'zip';
|
||||
UPDATE export_job SET status = 'running', file_path = NULL, progress_pct = 70
|
||||
WHERE event_id = 'aaaaaaaa-0000-0000-0000-000000000005' AND type = 'html';
|
||||
SQL
|
||||
fi
|
||||
|
||||
echo "▸ Snapshotting what is downloadable under the OLD model…"
|
||||
psql -q <<'SQL'
|
||||
CREATE TABLE _rehearsal_before AS
|
||||
SELECT j.event_id, j.type
|
||||
FROM export_job j JOIN event e ON e.id = j.event_id
|
||||
WHERE e.export_released_at IS NOT NULL
|
||||
AND j.status = 'done'
|
||||
AND ((j.type = 'zip' AND e.export_zip_ready) OR (j.type = 'html' AND e.export_html_ready));
|
||||
|
||||
-- For assertion 2: the exact flags a rollback has to reproduce.
|
||||
CREATE TABLE _rehearsal_flags AS
|
||||
SELECT id, export_zip_ready, export_html_ready FROM event;
|
||||
SQL
|
||||
|
||||
before=$(psql -tAc "SELECT count(*) FROM _rehearsal_before")
|
||||
echo " → $before (event,type) pair(s) downloadable before."
|
||||
|
||||
# The assertion. A symmetric difference of zero means the backfill carried the old notion of
|
||||
# "downloadable" across EXACTLY: nothing gained (a stale keepsake resurrected), nothing lost (a
|
||||
# guest's keepsake silently 404s).
|
||||
assert_up_preserves() {
|
||||
local label="$1" diff
|
||||
diff=$(psql -tAc "
|
||||
SELECT count(*) FROM (
|
||||
(SELECT event_id, type FROM _rehearsal_before
|
||||
EXCEPT SELECT event_id, type FROM export_current WHERE status = 'done')
|
||||
UNION ALL
|
||||
(SELECT event_id, type FROM export_current WHERE status = 'done'
|
||||
EXCEPT SELECT event_id, type FROM _rehearsal_before)
|
||||
) d")
|
||||
if [[ "$diff" != "0" ]]; then
|
||||
echo "✗ FAIL ($label): $diff (event,type) pair(s) changed downloadability across the migration."
|
||||
psql -c "
|
||||
(SELECT 'LOST — guests can no longer download this' AS problem, event_id, type FROM _rehearsal_before
|
||||
EXCEPT ALL SELECT 'LOST — guests can no longer download this', event_id, type FROM export_current WHERE status='done')
|
||||
UNION ALL
|
||||
(SELECT 'GAINED — a stale keepsake became downloadable', event_id, type FROM export_current WHERE status='done'
|
||||
EXCEPT ALL SELECT 'GAINED — a stale keepsake became downloadable', event_id, type FROM _rehearsal_before)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ $label: downloadability preserved exactly ($before pair(s))."
|
||||
}
|
||||
|
||||
echo "▸ Applying 014 (up)…"
|
||||
psql -q < "$MIG_DIR/014_export_epoch.up.sql"
|
||||
assert_up_preserves "up"
|
||||
|
||||
echo "▸ Applying 014 (down) — the rollback path…"
|
||||
psql -q < "$MIG_DIR/014_export_epoch.down.sql"
|
||||
|
||||
# The down migration is an inverse UP TO DRIFT, not a bit-exact inverse — deliberately.
|
||||
#
|
||||
# The old model let a ready flag disagree with its job row (flag TRUE over a running/failed job):
|
||||
# the flag was a CACHED copy of a derivation, and keeping a cache in agreement with its source
|
||||
# across concurrent workers is precisely what it kept failing at. The down migration re-derives the
|
||||
# flags from the epoch state, so such a pair comes back FALSE. That HEALS the drift rather than
|
||||
# faithfully restoring corruption, and it costs nothing: a flag over a non-done job had no file to
|
||||
# serve (file_path is NULL until the worker finishes), so the old code 404'd on it anyway.
|
||||
#
|
||||
# What a rollback must NOT do is drop a flag for a keepsake that was genuinely downloadable
|
||||
# (flag set AND the job actually done). That is the assertion.
|
||||
lost=$(psql -tAc "
|
||||
SELECT count(*) FROM _rehearsal_before b
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM event e
|
||||
WHERE e.id = b.event_id
|
||||
AND ((b.type = 'zip' AND e.export_zip_ready) OR (b.type = 'html' AND e.export_html_ready)))")
|
||||
if [[ "$lost" != "0" ]]; then
|
||||
echo "✗ FAIL (down): $lost downloadable keepsake(s) lost their ready flag — the rollback is LOSSY."
|
||||
psql -c "
|
||||
SELECT b.event_id, b.type, 'was downloadable, flag not restored' AS problem
|
||||
FROM _rehearsal_before b
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM event e
|
||||
WHERE e.id = b.event_id
|
||||
AND ((b.type = 'zip' AND e.export_zip_ready) OR (b.type = 'html' AND e.export_html_ready)))"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ down: every downloadable keepsake kept its flag — the old image sees a working state."
|
||||
|
||||
healed=$(psql -tAc "
|
||||
SELECT count(*) FROM event e JOIN _rehearsal_flags f ON f.id = e.id
|
||||
WHERE (e.export_zip_ready, e.export_html_ready) IS DISTINCT FROM (f.export_zip_ready, f.export_html_ready)")
|
||||
if [[ "$healed" != "0" ]]; then
|
||||
echo " ℹ $healed event(s) came back with a CLEARED flag that had drifted from its job row."
|
||||
echo " Not a loss — those had no file to serve. The rollback normalises them; the old code"
|
||||
echo " will rebuild on the next release. Listing them so the behaviour is not a surprise:"
|
||||
psql -c "SELECT e.id, f.export_zip_ready AS was_zip, e.export_zip_ready AS now_zip,
|
||||
f.export_html_ready AS was_html, e.export_html_ready AS now_html
|
||||
FROM event e JOIN _rehearsal_flags f ON f.id = e.id
|
||||
WHERE (e.export_zip_ready, e.export_html_ready)
|
||||
IS DISTINCT FROM (f.export_zip_ready, f.export_html_ready)"
|
||||
fi
|
||||
|
||||
echo "▸ Re-applying 014 (up) — roll forward after a rollback…"
|
||||
psql -q < "$MIG_DIR/014_export_epoch.up.sql"
|
||||
assert_up_preserves "up (after rollback)"
|
||||
|
||||
echo
|
||||
echo "✓ Rehearsal passed. 014 is safe to deploy against this dataset."
|
||||
[[ -z "$DUMP" ]] && echo " (synthetic data — re-run with a real pg_dump before you deploy for real)"
|
||||
exit 0
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use chrono::Utc;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -39,8 +39,11 @@ pub async fn join(
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await;
|
||||
if rate_limits_on && join_rate_on
|
||||
&& !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60))
|
||||
if rate_limits_on
|
||||
&& join_rate_on
|
||||
&& !state
|
||||
.rate_limiter
|
||||
.check(format!("join:{ip}"), 5, Duration::from_secs(60))
|
||||
{
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
@@ -80,8 +83,7 @@ pub async fn join(
|
||||
|
||||
// Generate a 4-digit PIN
|
||||
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
||||
let pin_hash =
|
||||
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
let pin_hash = bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
// The pre-check above is racy: two simultaneous joins with the same name can both
|
||||
// pass it, and the DB's unique index then rejects the loser. Map that unique
|
||||
@@ -178,8 +180,7 @@ pub async fn recover(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
|
||||
let users =
|
||||
User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
|
||||
let users = User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
|
||||
|
||||
if users.is_empty() {
|
||||
// No user with this name. Run a throwaway bcrypt verify so this branch takes
|
||||
@@ -208,8 +209,7 @@ pub async fn recover(
|
||||
User::reset_pin_attempts(&state.pool, user.id).await?;
|
||||
}
|
||||
|
||||
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
|
||||
.unwrap_or(false);
|
||||
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash).unwrap_or(false);
|
||||
|
||||
if pin_matches {
|
||||
// Reset failed attempts on success
|
||||
@@ -289,13 +289,13 @@ pub async fn admin_login(
|
||||
// honest typos.
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let admin_rate_on = config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
|
||||
if rate_limits_on && admin_rate_on
|
||||
&& !state.rate_limiter.check(
|
||||
format!("admin_login:{ip}"),
|
||||
5,
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
let admin_rate_on =
|
||||
config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
|
||||
if rate_limits_on
|
||||
&& admin_rate_on
|
||||
&& !state
|
||||
.rate_limiter
|
||||
.check(format!("admin_login:{ip}"), 5, Duration::from_secs(60))
|
||||
{
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||
@@ -303,8 +303,7 @@ pub async fn admin_login(
|
||||
));
|
||||
}
|
||||
|
||||
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash)
|
||||
.unwrap_or(false);
|
||||
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash).unwrap_or(false);
|
||||
|
||||
if !valid {
|
||||
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
||||
@@ -330,8 +329,8 @@ pub async fn admin_login(
|
||||
let dummy_pin: String = (0..32)
|
||||
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
|
||||
.collect();
|
||||
let dummy_hash = bcrypt::hash(&dummy_pin, 4)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
let dummy_hash =
|
||||
bcrypt::hash(&dummy_pin, 4).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
|
||||
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
|
||||
.bind(user.id)
|
||||
@@ -364,10 +363,7 @@ pub async fn admin_login(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
pub async fn logout(State(state): State<AppState>, auth: AuthUser) -> Result<StatusCode, AppError> {
|
||||
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,9 @@ impl FromRequestParts<AppState> for AuthUser {
|
||||
let user = Session::find_user_by_token_hash(&state.pool, &token_hash)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?
|
||||
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
|
||||
.ok_or_else(|| {
|
||||
AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into())
|
||||
})?;
|
||||
|
||||
// Touch last_seen_at AND slide the session's expiry forward (fire-and-forget), so
|
||||
// an active client's session renews instead of hitting the fixed 30-day cliff.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
|
||||
/// Well-known dev JWT secret shipped in `.env.example`. If APP_ENV=production
|
||||
/// we refuse to start with this value; otherwise we warn loudly.
|
||||
@@ -67,8 +67,7 @@ pub struct AppConfig {
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let app_env =
|
||||
std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
|
||||
let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
|
||||
let is_prod = app_env.eq_ignore_ascii_case("production");
|
||||
|
||||
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
||||
@@ -77,18 +76,15 @@ impl AppConfig {
|
||||
validate_secrets(is_prod, &jwt_secret, &admin_password_hash)?;
|
||||
|
||||
Ok(Self {
|
||||
database_url: std::env::var("DATABASE_URL")
|
||||
.context("DATABASE_URL must be set")?,
|
||||
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?,
|
||||
jwt_secret,
|
||||
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
|
||||
.unwrap_or_else(|_| "30".to_string())
|
||||
.parse()
|
||||
.context("SESSION_EXPIRY_DAYS must be a number")?,
|
||||
admin_password_hash,
|
||||
event_name: std::env::var("EVENT_NAME")
|
||||
.unwrap_or_else(|_| "EventSnap".to_string()),
|
||||
event_slug: std::env::var("EVENT_SLUG")
|
||||
.context("EVENT_SLUG must be set")?,
|
||||
event_name: std::env::var("EVENT_NAME").unwrap_or_else(|_| "EventSnap".to_string()),
|
||||
event_slug: std::env::var("EVENT_SLUG").context("EVENT_SLUG must be set")?,
|
||||
media_path: PathBuf::from(
|
||||
std::env::var("MEDIA_PATH").unwrap_or_else(|_| "/media".to_string()),
|
||||
),
|
||||
@@ -120,7 +116,10 @@ mod tests {
|
||||
// The exact string shipped in `.env` — >32 chars, so it must be caught by
|
||||
// the substring guard, not the length check.
|
||||
let err = validate_secrets(true, "change_me_to_a_random_64_byte_hex_string", REAL_HASH);
|
||||
assert!(err.is_err(), "placeholder JWT_SECRET must be rejected in prod");
|
||||
assert!(
|
||||
err.is_err(),
|
||||
"placeholder JWT_SECRET must be rejected in prod"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -154,7 +153,9 @@ mod tests {
|
||||
fn placeholder_detection_is_case_insensitive() {
|
||||
// looks_placeholder lowercases before matching — an upper/mixed-case
|
||||
// placeholder must still be rejected in prod.
|
||||
assert!(validate_secrets(true, "CHANGE_ME_TO_A_RANDOM_64_BYTE_HEX_STRING", REAL_HASH).is_err());
|
||||
assert!(
|
||||
validate_secrets(true, "CHANGE_ME_TO_A_RANDOM_64_BYTE_HEX_STRING", REAL_HASH).is_err()
|
||||
);
|
||||
assert!(validate_secrets(true, REAL_SECRET, "$2Y$12$PLACEHOLDER_replace_me").is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ pub enum AppError {
|
||||
BadRequest(String),
|
||||
Unauthorized(String),
|
||||
Forbidden(String),
|
||||
/// Uploads are temporarily locked (event closed / gallery released). Distinct from
|
||||
/// `Forbidden` so the client can tell this REVERSIBLE 403 apart from a permanent one
|
||||
/// (banned user, quota): the queued blob is kept and retried if the host reopens,
|
||||
/// instead of being purged like a genuinely-terminal rejection.
|
||||
UploadsLocked(String),
|
||||
NotFound(String),
|
||||
Conflict(String),
|
||||
/// Second field: optional retry-after seconds to include in the response.
|
||||
@@ -23,6 +28,7 @@ impl AppError {
|
||||
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
||||
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
|
||||
Self::UploadsLocked(_) => (StatusCode::FORBIDDEN, "uploads_locked"),
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||
@@ -36,6 +42,7 @@ impl AppError {
|
||||
Self::BadRequest(msg)
|
||||
| Self::Unauthorized(msg)
|
||||
| Self::Forbidden(msg)
|
||||
| Self::UploadsLocked(msg)
|
||||
| Self::NotFound(msg)
|
||||
| Self::Conflict(msg) => msg.clone(),
|
||||
Self::TooManyRequests(msg, _) => msg.clone(),
|
||||
@@ -68,10 +75,11 @@ impl IntoResponse for AppError {
|
||||
}
|
||||
|
||||
let mut resp = (status, axum::Json(body)).into_response();
|
||||
if let Some(secs) = retry_after_secs {
|
||||
if let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string()) {
|
||||
resp.headers_mut().insert(axum::http::header::RETRY_AFTER, val);
|
||||
}
|
||||
if let Some(secs) = retry_after_secs
|
||||
&& let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string())
|
||||
{
|
||||
resp.headers_mut()
|
||||
.insert(axum::http::header::RETRY_AFTER, val);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::middleware::RequireAdmin;
|
||||
@@ -45,19 +45,17 @@ pub async fn get_stats(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
|
||||
let (user_count,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1")
|
||||
let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1")
|
||||
.bind(event.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let (upload_count,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL")
|
||||
.bind(event.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let (upload_count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(event.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let (comment_count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM comment c
|
||||
JOIN upload u ON u.id = c.upload_id
|
||||
@@ -90,14 +88,17 @@ pub async fn get_config(
|
||||
State(state): State<AppState>,
|
||||
RequireAdmin(_auth): RequireAdmin,
|
||||
) -> Result<Json<HashMap<String, String>>, AppError> {
|
||||
let rows: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT key, value FROM config ORDER BY key")
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
let rows: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM config ORDER BY key")
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(rows.into_iter().collect()))
|
||||
}
|
||||
|
||||
/// Documents the wire shape of `PATCH /admin/config` (a flat `{key: value}` object).
|
||||
/// `patch_config` extracts the `HashMap` directly rather than going through this newtype, so it is
|
||||
/// never constructed in Rust — it stays as the serde-derived description of the request body.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
pub struct PatchConfigRequest(pub HashMap<String, String>);
|
||||
|
||||
@@ -128,6 +129,11 @@ pub async fn patch_config(
|
||||
"feed_rate_enabled",
|
||||
"export_rate_enabled",
|
||||
"join_rate_enabled",
|
||||
// These two per-area rate toggles are HONOURED by their handlers (auth/handlers.rs reads
|
||||
// `admin_login_rate_enabled` and `recover_rate_enabled`, both defaulting true) but were
|
||||
// missing from this allowlist — so the switch existed in code and could never be flipped.
|
||||
"admin_login_rate_enabled",
|
||||
"recover_rate_enabled",
|
||||
"quota_enabled",
|
||||
"storage_quota_enabled",
|
||||
"upload_count_quota_enabled",
|
||||
@@ -150,7 +156,7 @@ pub async fn patch_config(
|
||||
None => {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Ungültiger Wert für {key}: muss eine Zahl sein."
|
||||
)))
|
||||
)));
|
||||
}
|
||||
};
|
||||
if integer_only && n.fract() != 0.0 {
|
||||
@@ -288,22 +294,50 @@ pub async fn download_zip(
|
||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
||||
enforce_export_rate(&state, &headers).await?;
|
||||
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
let path =
|
||||
resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
|
||||
serve_file(path, "Gallery.zip", "application/zip").await
|
||||
}
|
||||
|
||||
if !event.export_zip_ready {
|
||||
return Err(AppError::NotFound(
|
||||
"Der ZIP-Export ist noch nicht verfügbar.".into(),
|
||||
));
|
||||
}
|
||||
/// Resolve the on-disk path of the CURRENT export generation — readiness check and path lookup in
|
||||
/// ONE read, through the `export_current` view (migration 014).
|
||||
///
|
||||
/// This used to be two statements: the caller checked `event.export_zip_ready`, then this fetched
|
||||
/// `export_job.file_path`. A reopen landing between them served a keepsake that should have 404'd —
|
||||
/// the same stale-keepsake class, leaking through the read path, and it existed only because
|
||||
/// readiness was a stored copy rather than a derivation. `export_current` gives both facts from one
|
||||
/// consistent snapshot: it yields a row only when the event is released AND the job is `done` at the
|
||||
/// event's current epoch, so "is it ready" and "which file" can no longer disagree.
|
||||
async fn resolve_export_file(
|
||||
state: &AppState,
|
||||
export_type: &str,
|
||||
not_ready_msg: &str,
|
||||
) -> Result<std::path::PathBuf, AppError> {
|
||||
let file_path: Option<(Option<String>,)> = sqlx::query_as(
|
||||
"SELECT c.file_path FROM export_current c
|
||||
JOIN event e ON e.id = c.event_id
|
||||
WHERE e.slug = $1 AND c.type = $2::export_type AND c.status = 'done'",
|
||||
)
|
||||
.bind(&state.config.event_slug)
|
||||
.bind(export_type)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
|
||||
let path = state.config.export_path.join("Gallery.zip");
|
||||
let Some((Some(rel),)) = file_path else {
|
||||
return Err(AppError::NotFound(not_ready_msg.into()));
|
||||
};
|
||||
|
||||
// `file_path` is stored as `exports/<name>`; the base dir is already `export_path`, so
|
||||
// join only the file name (defends against any absolute/`..` content too).
|
||||
let name = std::path::Path::new(&rel)
|
||||
.file_name()
|
||||
.ok_or_else(|| AppError::NotFound("Exportdatei nicht gefunden.".into()))?;
|
||||
let path = state.config.export_path.join(name);
|
||||
if !path.exists() {
|
||||
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
|
||||
}
|
||||
|
||||
serve_file(path, "Gallery.zip", "application/zip").await
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub async fn download_html(
|
||||
@@ -314,21 +348,8 @@ pub async fn download_html(
|
||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
||||
enforce_export_rate(&state, &headers).await?;
|
||||
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
|
||||
if !event.export_html_ready {
|
||||
return Err(AppError::NotFound(
|
||||
"Der HTML-Export ist noch nicht verfügbar.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let path = state.config.export_path.join("Memories.zip");
|
||||
if !path.exists() {
|
||||
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
|
||||
}
|
||||
|
||||
let path =
|
||||
resolve_export_file(&state, "html", "Der HTML-Export ist noch nicht verfügbar.").await?;
|
||||
serve_file(path, "Memories.zip", "application/zip").await
|
||||
}
|
||||
|
||||
@@ -338,7 +359,7 @@ async fn serve_file(
|
||||
content_type: &str,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Response, StatusCode};
|
||||
use axum::http::{Response, StatusCode, header};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
let file = tokio::fs::File::open(&path)
|
||||
@@ -374,8 +395,19 @@ pub async fn export_status(
|
||||
|
||||
let released = event.export_released_at.is_some();
|
||||
|
||||
// ONE statement: the epoch comparison happens inside the query, against a single snapshot.
|
||||
// Binding the epoch read by a previous statement would let a release/regeneration commit in
|
||||
// between and yield `{released: true, zip: locked, html: locked}` — a state that never existed.
|
||||
//
|
||||
// Only jobs at the CURRENT epoch are reported. A row left behind by a retired generation (a
|
||||
// worker superseded mid-run) is meaningless — surfacing its frozen `running`/77% would show a
|
||||
// progress bar that never moves for a keepsake nobody is building. It reads as "locked" (no
|
||||
// current job), which is exactly what it is.
|
||||
let jobs: Vec<(String, String, i16)> = sqlx::query_as(
|
||||
"SELECT type::text, status::text, progress_pct FROM export_job WHERE event_id = $1",
|
||||
"SELECT j.type::text, j.status::text, j.progress_pct
|
||||
FROM export_job j
|
||||
JOIN event e ON e.id = j.event_id
|
||||
WHERE e.id = $1 AND j.epoch = e.export_epoch",
|
||||
)
|
||||
.bind(event.id)
|
||||
.fetch_all(&state.pool)
|
||||
@@ -384,9 +416,7 @@ pub async fn export_status(
|
||||
let job_status = |type_name: &str| {
|
||||
jobs.iter()
|
||||
.find(|(t, _, _)| t == type_name)
|
||||
.map(|(_, status, pct)| {
|
||||
serde_json::json!({ "status": status, "progress_pct": pct })
|
||||
})
|
||||
.map(|(_, status, pct)| serde_json::json!({ "status": status, "progress_pct": pct }))
|
||||
.unwrap_or_else(|| serde_json::json!({ "status": "locked", "progress_pct": 0 }))
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
@@ -130,7 +130,11 @@ pub async fn feed(
|
||||
|
||||
let has_more = rows.len() as i64 > limit;
|
||||
let rows: Vec<FeedRow> = rows.into_iter().take(limit as usize).collect();
|
||||
let next_cursor = if has_more { rows.last().map(|r| r.id) } else { None };
|
||||
let next_cursor = if has_more {
|
||||
rows.last().map(|r| r.id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Batch check which uploads the current user has liked
|
||||
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
|
||||
@@ -181,6 +185,12 @@ pub struct DeltaQuery {
|
||||
pub struct DeltaResponse {
|
||||
pub uploads: Vec<FeedUpload>,
|
||||
pub deleted_ids: Vec<Uuid>,
|
||||
/// Users whose uploads became hidden (banned / uploads_hidden) since `since`. A ban is
|
||||
/// not a soft-delete, so it never appears in `deleted_ids`; without this, a client that
|
||||
/// missed the ephemeral `user-hidden` SSE (a reconnecting projector, most acutely) would
|
||||
/// keep displaying the banned user's already-loaded slides. The client evicts every
|
||||
/// upload from these users on receipt.
|
||||
pub hidden_user_ids: Vec<Uuid>,
|
||||
/// True when the upload query hit `DELTA_LIMIT`: the response carries only the
|
||||
/// newest slice of the gap, so the client must fall back to a full feed refresh
|
||||
/// rather than merging (the older missed uploads are absent and unrecoverable
|
||||
@@ -229,11 +239,17 @@ pub async fn feed_delta(
|
||||
// entire event's uploads in one response. If a client hits the cap it should
|
||||
// fall back to a full feed refresh rather than another delta.
|
||||
const DELTA_LIMIT: i64 = 200;
|
||||
// `>= since` (not `>`): `created_at` is not unique, so a strict `>` anchored to the exact
|
||||
// timestamp of the last-seen upload silently drops a SECOND upload committed in the same
|
||||
// microsecond — an unrecoverable live-update miss. `>=` re-includes the boundary rows;
|
||||
// the client merges by id (see the feed-delta handler's `seen` set), so the duplicate is
|
||||
// harmless while the tied upload is no longer lost. The cursor still advances to the
|
||||
// response's `server_time`, so this doesn't re-fetch on every subsequent delta.
|
||||
let rows = sqlx::query_as::<_, FeedRow>(
|
||||
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
|
||||
mime_type, caption, like_count, comment_count, created_at
|
||||
FROM v_feed
|
||||
WHERE event_id = $1 AND created_at > $2
|
||||
WHERE event_id = $1 AND created_at >= $2
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT $3",
|
||||
)
|
||||
@@ -247,9 +263,23 @@ pub async fn feed_delta(
|
||||
// client to full-refresh instead of merging a partial delta.
|
||||
let truncated = rows.len() as i64 >= DELTA_LIMIT;
|
||||
|
||||
// `>=` for the same tie-break reason as the uploads query above; re-signalling an
|
||||
// already-removed id is idempotent on the client (it filters its list by these ids).
|
||||
let deleted_ids: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT id FROM upload
|
||||
WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2",
|
||||
WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at >= $2",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.bind(q.since)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Users hidden since the cursor (ban / uploads_hidden). Uncapped like `deleted_ids` and
|
||||
// for the same reason — an eviction the client misses is unrecoverable via a later delta.
|
||||
let hidden_user_ids: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT id FROM \"user\"
|
||||
WHERE event_id = $1 AND uploads_hidden = TRUE
|
||||
AND uploads_hidden_at IS NOT NULL AND uploads_hidden_at >= $2",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.bind(q.since)
|
||||
@@ -285,6 +315,7 @@ pub async fn feed_delta(
|
||||
Ok(Json(DeltaResponse {
|
||||
uploads,
|
||||
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
|
||||
hidden_user_ids: hidden_user_ids.into_iter().map(|r| r.0).collect(),
|
||||
truncated,
|
||||
server_time,
|
||||
}))
|
||||
@@ -300,12 +331,11 @@ pub async fn hashtags(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<Json<Vec<HashtagCount>>, AppError> {
|
||||
let rows: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
let rows: Vec<(String, i64)> =
|
||||
sqlx::query_as("SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1")
|
||||
.bind(auth.event_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(
|
||||
rows.into_iter()
|
||||
@@ -335,14 +365,13 @@ async fn get_liked_set(
|
||||
if upload_ids.is_empty() {
|
||||
return std::collections::HashSet::new();
|
||||
}
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(upload_ids)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let rows: Vec<(Uuid,)> =
|
||||
sqlx::query_as("SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)")
|
||||
.bind(user_id)
|
||||
.bind(upload_ids)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
rows.into_iter().map(|r| r.0).collect()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
@@ -12,6 +12,7 @@ use crate::models::event::Event;
|
||||
use crate::models::session::Session;
|
||||
use crate::models::upload::Upload;
|
||||
use crate::models::user::UserRole;
|
||||
use crate::services::export::Affects;
|
||||
use crate::state::{AppState, SseEvent};
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
@@ -56,7 +57,6 @@ async fn remaining_operators(
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetRoleRequest {
|
||||
pub role: String,
|
||||
@@ -114,7 +114,9 @@ pub async fn ban_user(
|
||||
// The ban request carries no body — ban always hides (no per-request options).
|
||||
// Cannot ban yourself or another host/admin
|
||||
if user_id == auth.user_id {
|
||||
return Err(AppError::BadRequest("Du kannst dich nicht selbst sperren.".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"Du kannst dich nicht selbst sperren.".into(),
|
||||
));
|
||||
}
|
||||
let target = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
||||
@@ -125,8 +127,12 @@ pub async fn ban_user(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
|
||||
if target.0 == "admin" || (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin) {
|
||||
return Err(AppError::Forbidden("Du kannst diesen Benutzer nicht sperren.".into()));
|
||||
if target.0 == "admin"
|
||||
|| (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin)
|
||||
{
|
||||
return Err(AppError::Forbidden(
|
||||
"Du kannst diesen Benutzer nicht sperren.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Floor: never leave the event with zero operators. Banning removes the target from
|
||||
@@ -148,14 +154,34 @@ pub async fn ban_user(
|
||||
// (enforced live on the write handlers + Require{Host,Admin}). Revoking sessions would
|
||||
// contradict that model, break the documented "banned guest can still download the
|
||||
// keepsake" flow, and be ineffective anyway (the user could just /recover a new session).
|
||||
//
|
||||
// The ban and the keepsake invalidation are ONE transaction — see `host_delete_upload`.
|
||||
let mut tx = state.pool.begin().await?;
|
||||
sqlx::query(
|
||||
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = TRUE WHERE id = $1 AND event_id = $2",
|
||||
"UPDATE \"user\"
|
||||
SET is_banned = TRUE, uploads_hidden = TRUE, uploads_hidden_at = NOW()
|
||||
WHERE id = $1 AND event_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(auth.event_id)
|
||||
.execute(&state.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// A ban hides the user's uploads EVERYWHERE — and the keepsake is the place that matters most,
|
||||
// because it is the copy people keep. The export already filters `is_banned = FALSE`, so a
|
||||
// FUTURE export excludes them; without this, an ALREADY-RELEASED archive would keep serving a
|
||||
// banned user's photos forever. Same class as a takedown, so same treatment.
|
||||
let regen = crate::services::export::invalidate_and_arm(
|
||||
&mut tx,
|
||||
&state.config.event_slug,
|
||||
Affects::Both,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if let Some(r) = regen {
|
||||
start_regen(&state, r);
|
||||
}
|
||||
|
||||
// Evict their content live from every feed + the diashow so it disappears without
|
||||
// each viewer having to reload. (Their own SSE stream is separately dropped by the
|
||||
// is_banned revalidation in `sse::stream`, so they stop receiving live pushes while
|
||||
@@ -201,17 +227,35 @@ pub async fn unban_user(
|
||||
}
|
||||
|
||||
// Unban restores visibility too: ban set `uploads_hidden = TRUE`, so clearing only
|
||||
// `is_banned` would leave their content invisible. Clear both.
|
||||
// `is_banned` would leave their content invisible. Clear all three (the timestamp too,
|
||||
// so a future ban stamps a fresh `uploads_hidden_at` the reconnect delta will replay).
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE \"user\" SET is_banned = FALSE, uploads_hidden = FALSE WHERE id = $1 AND event_id = $2",
|
||||
"UPDATE \"user\"
|
||||
SET is_banned = FALSE, uploads_hidden = FALSE, uploads_hidden_at = NULL
|
||||
WHERE id = $1 AND event_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(auth.event_id)
|
||||
.execute(&state.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Benutzer nicht gefunden.".into()));
|
||||
}
|
||||
|
||||
// The mirror of the ban case: an unban RESTORES their uploads to the export query, so an
|
||||
// already-released keepsake is now missing content it should contain. Rebuild it.
|
||||
let regen = crate::services::export::invalidate_and_arm(
|
||||
&mut tx,
|
||||
&state.config.event_slug,
|
||||
Affects::Both,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if let Some(r) = regen {
|
||||
start_regen(&state, r);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
actor_user_id = %auth.user_id,
|
||||
target_user_id = %user_id,
|
||||
@@ -221,6 +265,54 @@ pub async fn unban_user(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Force a keepsake rebuild. The ESCAPE HATCH.
|
||||
///
|
||||
/// Without this, a failed or stranded export is terminal at runtime: `release_gallery` refuses an
|
||||
/// already-released event ("bereits freigegeben"), `recover_exports` only runs at boot, and there is
|
||||
/// no other retry path — so the host's only options were restarting the container or reopening the
|
||||
/// event (which unlocks uploads to every guest and discards the release). This is also the recovery
|
||||
/// path for a keepsake that went stale for any reason we haven't thought of.
|
||||
pub async fn rebuild_export(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(auth): RequireHost,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let regen = crate::services::export::invalidate_and_arm(
|
||||
&mut tx,
|
||||
&state.config.event_slug,
|
||||
Affects::Both,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let Some(r) = regen else {
|
||||
return Err(AppError::BadRequest(
|
||||
"Die Galerie ist nicht freigegeben — es gibt nichts neu zu erzeugen.".into(),
|
||||
));
|
||||
};
|
||||
|
||||
// No debounce: this is an explicit, deliberate host action, not a burst.
|
||||
for export_type in ["zip", "html"] {
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"export-progress",
|
||||
serde_json::json!({ "type": export_type, "progress_pct": 0 }).to_string(),
|
||||
));
|
||||
}
|
||||
crate::services::export::spawn_export_jobs(
|
||||
r.event_id,
|
||||
r.event_name,
|
||||
r.epoch,
|
||||
std::time::Duration::ZERO,
|
||||
state.pool.clone(),
|
||||
state.config.media_path.clone(),
|
||||
state.config.export_path.clone(),
|
||||
state.sse_tx.clone(),
|
||||
);
|
||||
|
||||
tracing::info!(actor_user_id = %auth.user_id, "host: rebuild_export");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn set_role(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(auth): RequireHost,
|
||||
@@ -238,14 +330,15 @@ pub async fn set_role(
|
||||
_ => {
|
||||
return Err(AppError::BadRequest(
|
||||
"Ungültige Rolle. Erlaubt: guest, host.".into(),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Look up the current role so we can apply the host-vs-admin guard. Hosts may
|
||||
// promote guests and demote *other* hosts (the user explicitly requested this
|
||||
// expansion). Hosts may not touch admins. Admins may do anything (except change
|
||||
// themselves, blocked above).
|
||||
// Look up the current role so we can apply the host-vs-admin guard. A plain host may
|
||||
// promote/demote GUESTS only; it may not change any host's or admin's role (see the
|
||||
// guard below — this closes the demote-a-peer-host→ban/PIN-reset takeover chain, F1).
|
||||
// Only an admin may change a host's role. Admins may do anything except change
|
||||
// themselves (blocked above).
|
||||
let target = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
||||
)
|
||||
@@ -334,13 +427,12 @@ pub async fn reset_user_pin(
|
||||
_ => {
|
||||
return Err(AppError::Forbidden(
|
||||
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
|
||||
))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
||||
let pin_hash =
|
||||
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
let pin_hash = bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
@@ -356,8 +448,13 @@ pub async fn reset_user_pin(
|
||||
.await?;
|
||||
|
||||
// A PIN reset means the old credential is compromised/forgotten — revoke every
|
||||
// existing session so old devices must re-authenticate with the new PIN.
|
||||
let _ = Session::delete_all_for_user(&state.pool, user_id).await;
|
||||
// existing session so old devices must re-authenticate with the new PIN. This is a
|
||||
// security-relevant revoke: if it fails, the old sessions stay valid (sessions are
|
||||
// token- not PIN-bound), so surface the error in logs rather than swallowing it
|
||||
// silently while reporting success to the host.
|
||||
if let Err(e) = Session::delete_all_for_user(&state.pool, user_id).await {
|
||||
tracing::error!(error = ?e, user_id = %user_id, "PIN reset: failed to revoke sessions");
|
||||
}
|
||||
|
||||
// Resolve any pending in-app "I forgot my PIN" request for this user.
|
||||
let _ = sqlx::query("DELETE FROM pin_reset_request WHERE user_id = $1")
|
||||
@@ -424,6 +521,45 @@ pub async fn dismiss_pin_reset_request(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Content changed AFTER the gallery was released — regenerate the keepsake.
|
||||
///
|
||||
/// Deleting a photo used to remove it from the live feed but leave it in the already-generated
|
||||
/// archive FOREVER: the old `if ready { continue }` skip guaranteed the export was never rebuilt.
|
||||
/// For a takedown ("please remove my photo") that is the one place it most needs to disappear.
|
||||
///
|
||||
/// Bumping the epoch (while staying released) retires the current generation, so the stale archive
|
||||
/// stops being downloadable the instant the delete commits, and a fresh worker rebuilds it without
|
||||
/// the removed content. The download 404s in the meantime, which is the correct answer — serving
|
||||
/// the old archive would serve the deleted photo.
|
||||
/// Start the workers for a regeneration that was armed inside a (now-committed) transaction, and
|
||||
/// tell every client the current keepsake just became undownloadable.
|
||||
///
|
||||
/// The SSE matters: bumping the epoch retires the archive INSTANTLY, so `/export/zip` starts 404ing
|
||||
/// the moment the change commits. Without a nudge, a guest sitting on `/export` keeps rendering an
|
||||
/// enabled "download" button for the whole rebuild. Both the nav badge and the export page already
|
||||
/// refetch `/export/status` on `export-progress`, so a 0% tick is the cheapest correct signal.
|
||||
pub fn start_regen(state: &AppState, regen: crate::services::export::PendingRegen) {
|
||||
for export_type in ["zip", "html"] {
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"export-progress",
|
||||
serde_json::json!({ "type": export_type, "progress_pct": 0 }).to_string(),
|
||||
));
|
||||
}
|
||||
crate::services::export::spawn_export_jobs(
|
||||
regen.event_id,
|
||||
regen.event_name,
|
||||
regen.epoch,
|
||||
// Debounced: a takedown pass is a burst, and each request retires the last generation. The
|
||||
// delay lets superseded workers fail their claim and do zero work instead of each building
|
||||
// a full archive. See export::REGEN_DEBOUNCE.
|
||||
crate::services::export::REGEN_DEBOUNCE,
|
||||
state.pool.clone(),
|
||||
state.config.media_path.clone(),
|
||||
state.config.export_path.clone(),
|
||||
state.sse_tx.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn host_delete_upload(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(auth): RequireHost,
|
||||
@@ -433,15 +569,29 @@ pub async fn host_delete_upload(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||
|
||||
let deleted = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
|
||||
// The delete and the keepsake invalidation are ONE transaction: if the delete committed and the
|
||||
// invalidation didn't, the taken-down photo would stay downloadable forever and nothing would
|
||||
// notice (the keepsake still looks complete, and the host can no longer find the upload to retry).
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let deleted = Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id).await?;
|
||||
if !deleted {
|
||||
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
|
||||
}
|
||||
let regen = crate::services::export::invalidate_and_arm(
|
||||
&mut tx,
|
||||
&state.config.event_slug,
|
||||
Affects::Both,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"upload-deleted",
|
||||
serde_json::json!({ "upload_id": upload.id }).to_string(),
|
||||
));
|
||||
if let Some(r) = regen {
|
||||
start_regen(&state, r);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
actor_user_id = %auth.user_id,
|
||||
@@ -458,15 +608,29 @@ pub async fn host_delete_comment(
|
||||
RequireHost(auth): RequireHost,
|
||||
Path(comment_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let deleted =
|
||||
Comment::soft_delete_in_event(&state.pool, comment_id, auth.event_id).await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let deleted = Comment::soft_delete_in_event(&mut tx, comment_id, auth.event_id).await?;
|
||||
if !deleted {
|
||||
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
||||
}
|
||||
// Only the HTML viewer embeds comments — the ZIP is media-only, so it is carried forward rather
|
||||
// than rebuilt. Otherwise moderating one comment would 404 the photo download for minutes to
|
||||
// change nothing in it.
|
||||
let regen = crate::services::export::invalidate_and_arm(
|
||||
&mut tx,
|
||||
&state.config.event_slug,
|
||||
Affects::ViewerOnly,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"comment-deleted",
|
||||
serde_json::json!({ "comment_id": comment_id }).to_string(),
|
||||
));
|
||||
if let Some(r) = regen {
|
||||
start_regen(&state, r);
|
||||
}
|
||||
tracing::info!(
|
||||
actor_user_id = %auth.user_id,
|
||||
event_id = %auth.event_id,
|
||||
@@ -500,16 +664,20 @@ pub async fn open_event(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// Reopening also invalidates any prior release: the keepsake was snapshotted at
|
||||
// release time, so allowing new uploads afterwards would silently diverge the live
|
||||
// feed from the frozen export. Clearing `export_released_at` (and the readiness
|
||||
// flags) lets the host re-release later to regenerate a correct, complete keepsake.
|
||||
// Reopening invalidates any prior release: the keepsake was snapshotted at release time, so
|
||||
// allowing new uploads afterwards would silently diverge the live feed from the frozen export.
|
||||
//
|
||||
// ONE statement retires the entire export generation. Bumping `export_epoch` in the same write
|
||||
// that clears `export_released_at` instantly invalidates every in-flight worker, every `done`
|
||||
// row and all readiness — because readiness is DERIVED from this epoch (migration 014), not
|
||||
// stored. There is no export_job row to touch and nothing to keep in sync, so this needs no
|
||||
// transaction. Any worker still streaming holds the old epoch and is now inert: its
|
||||
// epoch-guarded finalize matches nothing, and it discards its own output.
|
||||
let result = sqlx::query(
|
||||
"UPDATE event
|
||||
SET uploads_locked_at = NULL,
|
||||
SET uploads_locked_at = NULL,
|
||||
export_released_at = NULL,
|
||||
export_zip_ready = FALSE,
|
||||
export_html_ready = FALSE
|
||||
export_epoch = export_epoch + 1
|
||||
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
|
||||
)
|
||||
.bind(&state.config.event_slug)
|
||||
@@ -527,24 +695,37 @@ pub async fn release_gallery(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// Atomic claim: the conditional UPDATE is the sole gate, so two concurrent
|
||||
// release calls can't both pass a check-then-set and double-enqueue exports.
|
||||
// rows_affected == 0 means someone already released.
|
||||
// The release claim, the epoch bump, the upload lock and BOTH job rows are written in ONE
|
||||
// transaction. Two reasons, both of which were live bugs:
|
||||
//
|
||||
// Releasing also locks uploads in the same statement (release ⇒ lock). Otherwise a
|
||||
// guest whose offline upload reconnects *after* the export snapshot would land in the
|
||||
// live feed but not in the downloaded keepsake — a silent, non-regenerable data loss.
|
||||
// `COALESCE` preserves an earlier explicit lock time rather than overwriting it.
|
||||
let result = sqlx::query(
|
||||
// 1. Cancellation. This handler used to commit `export_released_at` and only THEN await the
|
||||
// enqueue. Axum drops the handler future when the client disconnects (closed tab, proxy
|
||||
// timeout), which left the event released and uploads locked with ZERO export_job rows and
|
||||
// no workers: downloads 404 forever and the host cannot retry, because release_gallery
|
||||
// rejects an already-released event ("bereits freigegeben"). Only a restart escaped it.
|
||||
// Now nothing is committed until every row is written, and the workers are spawned AFTER
|
||||
// the commit (a detached `tokio::spawn` survives cancellation).
|
||||
// 2. Atomicity vs. a concurrent reopen. Bumping the epoch in the same statement that sets
|
||||
// `export_released_at` means no worker and no reader can ever observe "released again but
|
||||
// the generation hasn't moved on yet" — the window every previous fix kept leaving open.
|
||||
//
|
||||
// Release also locks uploads in the same statement (release ⇒ lock), so the export snapshot is
|
||||
// taken against a frozen upload set. `COALESCE` preserves an earlier explicit lock time.
|
||||
let mut tx = state.pool.begin().await?;
|
||||
|
||||
let claimed: Option<(Uuid, String, i64)> = sqlx::query_as(
|
||||
"UPDATE event
|
||||
SET export_released_at = NOW(),
|
||||
uploads_locked_at = COALESCE(uploads_locked_at, NOW())
|
||||
WHERE slug = $1 AND export_released_at IS NULL",
|
||||
uploads_locked_at = COALESCE(uploads_locked_at, NOW()),
|
||||
export_epoch = export_epoch + 1
|
||||
WHERE slug = $1 AND export_released_at IS NULL
|
||||
RETURNING id, name, export_epoch",
|
||||
)
|
||||
.bind(&state.config.event_slug)
|
||||
.execute(&state.pool)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
|
||||
let Some((event_id, event_name, epoch)) = claimed else {
|
||||
// Distinguish "no such event" from "already released" for a clean error.
|
||||
let exists = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
@@ -554,28 +735,30 @@ pub async fn release_gallery(
|
||||
} else {
|
||||
AppError::NotFound("Event nicht gefunden.".into())
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Release locked uploads too — tell any open composer to flip to the locked UI live
|
||||
// rather than discovering it via a rejected upload.
|
||||
// Arm both types at THIS epoch. No "skip the type that's already ready" check any more: the
|
||||
// epoch bump above retired every prior generation, so there is nothing to preserve and nothing
|
||||
// to be fooled by. (That skip was how a stale ready flag used to suppress regeneration.)
|
||||
crate::services::export::enqueue_jobs_at_epoch(&mut tx, event_id, epoch).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Release locks uploads too — tell any open composer to flip to the locked UI live rather than
|
||||
// discovering it via a rejected upload.
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||
|
||||
// We won the claim — load the event for its id/name to enqueue export jobs.
|
||||
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
|
||||
// Enqueue + spawn via the shared path so a re-release (after reopen) regenerates
|
||||
// cleanly and startup recovery uses identical logic.
|
||||
crate::services::export::enqueue_and_spawn_exports(
|
||||
event.id,
|
||||
event.name,
|
||||
// Detached — survives this handler being cancelled.
|
||||
crate::services::export::spawn_export_jobs(
|
||||
event_id,
|
||||
event_name,
|
||||
epoch,
|
||||
std::time::Duration::ZERO,
|
||||
state.pool.clone(),
|
||||
state.config.media_path.clone(),
|
||||
state.config.export_path.clone(),
|
||||
state.sse_tx.clone(),
|
||||
)
|
||||
.await?;
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
//! account page loads this once on mount instead of issuing several round trips.
|
||||
//! - `GET /api/v1/me/quota` — live per-user storage quota estimate.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::auth::middleware::AuthUser;
|
||||
@@ -73,12 +73,19 @@ pub async fn get_context(
|
||||
|
||||
let privacy_note = config::get_str(&state.config_cache, "privacy_note", "").await;
|
||||
let quota_enabled = config::get_bool(&state.config_cache, "quota_enabled", true).await;
|
||||
let storage_quota_enabled = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
||||
let storage_quota_enabled =
|
||||
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
||||
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?;
|
||||
let uploads_locked = event.as_ref().map(|e| e.uploads_locked_at.is_some()).unwrap_or(false);
|
||||
let gallery_released = event.as_ref().map(|e| e.export_released_at.is_some()).unwrap_or(false);
|
||||
let event =
|
||||
crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug).await?;
|
||||
let uploads_locked = event
|
||||
.as_ref()
|
||||
.map(|e| e.uploads_locked_at.is_some())
|
||||
.unwrap_or(false);
|
||||
let gallery_released = event
|
||||
.as_ref()
|
||||
.map(|e| e.export_released_at.is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(Json(MeContextDto {
|
||||
user_id: user.id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Unauthenticated, read-only endpoints safe to expose before a user has joined.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
@@ -220,10 +220,22 @@ pub async fn delete_comment(
|
||||
|
||||
// Event-scope: soft_delete_in_event only matches comments whose upload is in
|
||||
// the caller's event, so a cross-event comment_id resolves to a 404 here.
|
||||
let deleted = Comment::soft_delete_in_event(&state.pool, comment_id, auth.event_id).await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let deleted = Comment::soft_delete_in_event(&mut tx, comment_id, auth.event_id).await?;
|
||||
if !deleted {
|
||||
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
||||
}
|
||||
// Comments live only in the HTML viewer, so the ZIP is carried forward, not rebuilt.
|
||||
let regen = crate::services::export::invalidate_and_arm(
|
||||
&mut tx,
|
||||
&state.config.event_slug,
|
||||
crate::services::export::Affects::ViewerOnly,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if let Some(r) = regen {
|
||||
crate::handlers::host::start_regen(&state, r);
|
||||
}
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||
"comment-deleted",
|
||||
serde_json::json!({ "comment_id": comment_id, "upload_id": comment.upload_id }).to_string(),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::Json;
|
||||
use futures::stream::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
|
||||
use crate::auth::middleware::AuthUser;
|
||||
use crate::error::AppError;
|
||||
@@ -42,7 +42,10 @@ pub async fn issue_ticket(
|
||||
let server_time = sqlx::query_scalar("SELECT NOW()")
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(StreamTicketResponse { ticket, server_time }))
|
||||
Ok(Json(StreamTicketResponse {
|
||||
ticket,
|
||||
server_time,
|
||||
}))
|
||||
}
|
||||
|
||||
/// SSE stream endpoint. Authenticates via a single-use ticket (see
|
||||
@@ -82,11 +85,11 @@ pub async fn stream(
|
||||
|
||||
// The session is only checked once at open. Re-validate it periodically so a
|
||||
// logged-out, expired, OR banned session's stream is closed rather than kept alive
|
||||
// until the client happens to disconnect. Bounds a stale stream to ~60s. Banning
|
||||
// already revokes the user's sessions (so the row is gone), but re-reading the live
|
||||
// user row and dropping on `is_banned` is a cheap defense-in-depth. Only a
|
||||
// *definitive* gone/expired/banned state ends the stream; a transient DB error just
|
||||
// retries next tick.
|
||||
// until the client happens to disconnect. Bounds a stale stream to ~60s. NOTE: a ban is
|
||||
// deliberately read-only and does NOT revoke sessions (see `ban_user`), so the
|
||||
// `is_banned` re-read below is LOAD-BEARING — it is the only thing that stops a banned
|
||||
// user's live push stream. Do not remove it. Only a *definitive* gone/expired/banned
|
||||
// state ends the stream; a transient DB error just retries next tick.
|
||||
let pool = state.pool.clone();
|
||||
let session_hash = token_hash.clone();
|
||||
let session_gone = async move {
|
||||
|
||||
@@ -85,6 +85,26 @@ pub async fn truncate_all(
|
||||
// could serve the previous test's toggles.
|
||||
state.config_cache.invalidate();
|
||||
|
||||
// The other two in-memory singletons that TRUNCATE used to leave standing.
|
||||
//
|
||||
// `disk_cache` holds a free-space reading for up to its TTL. TRUNCATE has just deleted every
|
||||
// uploaded file, which materially changes free space — so without this the next test can
|
||||
// compute a storage quota from the PREVIOUS test's disk. That was harmless only while quotas
|
||||
// were globally disabled in e2e (they no longer are: see specs/02-upload/quota.spec.ts, which
|
||||
// steers the per-user limit off `free_disk_bytes`), i.e. two holes were masking each other.
|
||||
state.disk_cache.invalidate();
|
||||
|
||||
// `sse_tickets` maps a ticket to a session token hash. TRUNCATE deletes the sessions, so every
|
||||
// surviving ticket is a dangling reference to a user that no longer exists.
|
||||
state.sse_tickets.clear();
|
||||
|
||||
// Invalidate any in-flight/queued compression task spawned by the previous test. Without this a
|
||||
// task still waiting on the concurrency semaphore wakes AFTER this wipe, fails to find its
|
||||
// (now-deleted) file, and broadcasts upload-error/upload-deleted into the NEXT test's SSE
|
||||
// stream. (Export workers are already inert across a truncate: they are epoch-guarded on the
|
||||
// event row, and truncate gives the event a fresh random UUID, so their writes match nothing.)
|
||||
state.compression.bump_generation();
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Multipart, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -46,7 +47,8 @@ pub async fn upload(
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let upload_rate_on = config::get_bool(&state.config_cache, "upload_rate_enabled", true).await;
|
||||
if rate_limits_on && upload_rate_on {
|
||||
let upload_rate = config::get_i64(&state.config_cache, "upload_rate_per_hour", 10).await as usize;
|
||||
let upload_rate =
|
||||
config::get_i64(&state.config_cache, "upload_rate_per_hour", 10).await as usize;
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("upload:{}", auth.user_id),
|
||||
upload_rate,
|
||||
@@ -75,14 +77,19 @@ pub async fn upload(
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
if event.uploads_locked_at.is_some() {
|
||||
drain_multipart(multipart).await;
|
||||
return Err(AppError::Forbidden("Uploads sind gesperrt.".into()));
|
||||
// Reversible: a host can reopen the event, so the client keeps the queued blob and
|
||||
// retries on `event-opened` rather than purging it (UploadsLocked, not Forbidden).
|
||||
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
|
||||
}
|
||||
// Belt-and-suspenders on top of the lock (release ⇒ lock): once the gallery is
|
||||
// released the export has been snapshotted, so a late upload could never make it into
|
||||
// the keepsake. Reject it explicitly rather than silently diverging the live feed.
|
||||
// Also reversible (reopen clears `export_released_at`), so likewise UploadsLocked.
|
||||
if event.export_released_at.is_some() {
|
||||
drain_multipart(multipart).await;
|
||||
return Err(AppError::Forbidden("Galerie wurde bereits freigegeben.".into()));
|
||||
return Err(AppError::UploadsLocked(
|
||||
"Galerie wurde bereits freigegeben.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Read config limits from DB
|
||||
@@ -95,7 +102,10 @@ pub async fn upload(
|
||||
// On success the temp file is renamed into place under its detected extension.
|
||||
let upload_id = Uuid::new_v4();
|
||||
let event_slug = &state.config.event_slug;
|
||||
let originals_dir = state.config.media_path.join(format!("originals/{event_slug}"));
|
||||
let originals_dir = state
|
||||
.config
|
||||
.media_path
|
||||
.join(format!("originals/{event_slug}"));
|
||||
let temp_abs = originals_dir.join(format!("{upload_id}.tmp"));
|
||||
|
||||
let mut streamed: Option<(i64, Vec<u8>)> = None; // (size, head bytes for sniffing)
|
||||
@@ -133,12 +143,20 @@ pub async fn upload(
|
||||
streamed = Some(stream_field_to_file(field, &temp_abs, cap_bytes).await?);
|
||||
}
|
||||
"caption" => {
|
||||
caption =
|
||||
Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
|
||||
caption = Some(
|
||||
field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
"hashtags" => {
|
||||
hashtags_csv =
|
||||
Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
|
||||
hashtags_csv = Some(
|
||||
field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -162,14 +180,14 @@ pub async fn upload(
|
||||
// Validate caption length. Counted in chars (code points) to match the
|
||||
// "Zeichen" wording in the error message — `.len()` would be bytes and
|
||||
// reject perfectly valid German/emoji captions early.
|
||||
if let Some(ref cap) = caption {
|
||||
if cap.chars().count() > MAX_CAPTION_LENGTH {
|
||||
let _ = tokio::fs::remove_file(&temp_abs).await;
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Beschreibung ist zu lang. Maximum: {} Zeichen.",
|
||||
MAX_CAPTION_LENGTH
|
||||
)));
|
||||
}
|
||||
if let Some(ref cap) = caption
|
||||
&& cap.chars().count() > MAX_CAPTION_LENGTH
|
||||
{
|
||||
let _ = tokio::fs::remove_file(&temp_abs).await;
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Beschreibung ist zu lang. Maximum: {} Zeichen.",
|
||||
MAX_CAPTION_LENGTH
|
||||
)));
|
||||
}
|
||||
|
||||
// Determine the file type from its magic bytes and require it to be on the
|
||||
@@ -219,7 +237,8 @@ pub async fn upload(
|
||||
// number of active uploaders. Gated by master + per-area toggles so the admin can
|
||||
// disable it on trusted instances.
|
||||
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
|
||||
let storage_quota_on = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
||||
let storage_quota_on =
|
||||
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
||||
// When quota is enforced, this holds the byte ceiling so the increment UPDATE below can
|
||||
// enforce it atomically (`WHERE total + size <= limit`). Without that guard, two
|
||||
// concurrent uploads from the same user (e.g. phone + laptop) both pass this stale
|
||||
@@ -269,6 +288,36 @@ pub async fn upload(
|
||||
// bytes with no row to reclaim them (silent quota erosion / spurious lockout).
|
||||
let tx_result: Result<Upload, AppError> = async {
|
||||
let mut tx = state.pool.begin().await?;
|
||||
|
||||
// RE-CHECK THE LOCK, UNDER A ROW LOCK, INSIDE THE COMMIT TX.
|
||||
//
|
||||
// The pre-flight check at the top of this handler ran BEFORE we streamed the body — which
|
||||
// for a 500 MB video is minutes. Trusting it here is a TOCTOU that silently loses photos
|
||||
// from the keepsake, and it is the real cause of the "stale keepsake" bug that survived
|
||||
// three rounds of fixes inside the export state machine:
|
||||
//
|
||||
// 1. guest starts a big upload; the lock check passes (event open)
|
||||
// 2. host releases the gallery → uploads lock, export workers snapshot the uploads table
|
||||
// 3. this upload commits AFTER that snapshot → it shows up in the live feed but is
|
||||
// MISSING from the downloaded keepsake, permanently (nothing ever regenerates it)
|
||||
//
|
||||
// `FOR SHARE` conflicts with the `UPDATE event` in `release_gallery`, which serializes us
|
||||
// against it. Either we take the lock first — and release (hence the export snapshot) is
|
||||
// strictly ordered after our commit, so the snapshot CONTAINS this upload — or release
|
||||
// commits first and we observe the lock here and reject. Either way the keepsake is
|
||||
// complete. `UploadsLocked` (not Forbidden) is reversible: the client keeps the blob and
|
||||
// resumes it when the host reopens.
|
||||
let (locked_at, released_at): (Option<DateTime<Utc>>, Option<DateTime<Utc>>) =
|
||||
sqlx::query_as(
|
||||
"SELECT uploads_locked_at, export_released_at FROM event WHERE id = $1 FOR SHARE",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if locked_at.is_some() || released_at.is_some() {
|
||||
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
|
||||
}
|
||||
|
||||
// Increment the user's byte total. When a quota is in force, guard it atomically
|
||||
// (`total + size <= limit`) so two concurrent uploads can't both slip past the
|
||||
// stale pre-check — the loser's UPDATE matches 0 rows and we abort with the same
|
||||
@@ -285,11 +334,13 @@ pub async fn upload(
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
|
||||
.bind(auth.user_id)
|
||||
.bind(size)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
sqlx::query(
|
||||
"UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1",
|
||||
)
|
||||
.bind(auth.user_id)
|
||||
.bind(size)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
if inc.rows_affected() == 0 {
|
||||
return Err(AppError::QuotaExceeded(
|
||||
@@ -380,6 +431,15 @@ pub async fn edit_upload(
|
||||
|
||||
// Caption update + hashtag wipe-then-relink in one transaction, so a crash
|
||||
// mid-relink can't leave the upload with its hashtags stripped.
|
||||
//
|
||||
// Editing is intentionally allowed while uploads are locked or the gallery is released — like
|
||||
// comments and likes, the lock freezes *new uploads* only (USER_JOURNEYS §9.3). But a caption
|
||||
// is embedded in the HTML viewer keepsake (the ZIP holds media only — see export.rs), so an
|
||||
// edit AFTER release must regenerate the viewer, or the downloadable keepsake keeps showing the
|
||||
// old caption forever while the live feed shows the new one. Same atomicity as delete_upload:
|
||||
// the edit and its invalidation share one tx so a dropped handler can't leave them disagreeing.
|
||||
// `Affects::ViewerOnly` carries the finished ZIP forward (the media didn't change); when the
|
||||
// gallery isn't released, `invalidate_and_arm` returns None and this is a no-op.
|
||||
let mut tx = state.pool.begin().await?;
|
||||
if let Some(ref caption) = body.caption {
|
||||
Upload::update_caption(&mut *tx, upload_id, Some(caption)).await?;
|
||||
@@ -391,7 +451,16 @@ pub async fn edit_upload(
|
||||
Hashtag::link_to_upload(&mut *tx, upload_id, h.id).await?;
|
||||
}
|
||||
}
|
||||
let regen = crate::services::export::invalidate_and_arm(
|
||||
&mut tx,
|
||||
&state.config.event_slug,
|
||||
crate::services::export::Affects::ViewerOnly,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if let Some(r) = regen {
|
||||
crate::handlers::host::start_regen(&state, r);
|
||||
}
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -413,7 +482,20 @@ pub async fn delete_upload(
|
||||
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
|
||||
}
|
||||
|
||||
Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
|
||||
// Atomic with the keepsake invalidation: a guest removing their own photo must have it removed
|
||||
// from the downloadable archive too, and a half-applied delete would leave it there forever.
|
||||
let mut tx = state.pool.begin().await?;
|
||||
Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id).await?;
|
||||
let regen = crate::services::export::invalidate_and_arm(
|
||||
&mut tx,
|
||||
&state.config.event_slug,
|
||||
crate::services::export::Affects::Both,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if let Some(r) = regen {
|
||||
crate::handlers::host::start_regen(&state, r);
|
||||
}
|
||||
|
||||
// Evict the card live on every other feed + the projector diashow — otherwise
|
||||
// a self-deleted post lingers until each viewer manually reloads. Same event
|
||||
@@ -506,6 +588,9 @@ pub struct QuotaEstimate {
|
||||
pub limit_bytes: Option<i64>,
|
||||
pub active_uploaders: i64,
|
||||
pub free_disk_bytes: i64,
|
||||
/// The tolerance factor the limit above was computed with. Carried on the snapshot so the
|
||||
/// number is self-describing; no caller reads it back today.
|
||||
#[allow(dead_code)]
|
||||
pub tolerance: f64,
|
||||
}
|
||||
|
||||
@@ -522,15 +607,15 @@ fn quota_limit_bytes(free_disk: i64, tolerance: f64, active_uploaders: i64) -> i
|
||||
/// check (upload handler) or hide the UI (quota endpoint).
|
||||
pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
||||
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
|
||||
let storage_quota_on = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
||||
let storage_quota_on =
|
||||
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
||||
let tolerance = config::get_f64(&state.config_cache, "quota_tolerance", 0.75).await;
|
||||
|
||||
let (active_count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL",
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or((0,));
|
||||
let (active_count,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or((0,));
|
||||
let active = active_count.max(1);
|
||||
|
||||
// Cached disk reading. `None` means we couldn't resolve the media filesystem.
|
||||
@@ -574,7 +659,7 @@ async fn stream_media_file(
|
||||
cache_control: &str,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Response, StatusCode};
|
||||
use axum::http::{Response, StatusCode, header};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
if !absolute.exists() {
|
||||
@@ -653,7 +738,13 @@ pub async fn get_preview(
|
||||
.preview_path
|
||||
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?;
|
||||
let absolute = state.config.media_path.join(&rel);
|
||||
stream_media_file(&absolute, "image/jpeg".to_string(), "inline", "private, max-age=300").await
|
||||
stream_media_file(
|
||||
&absolute,
|
||||
"image/jpeg".to_string(),
|
||||
"inline",
|
||||
"private, max-age=300",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Streaming access to an upload's **thumbnail** (video poster). Gated identically to
|
||||
@@ -669,7 +760,13 @@ pub async fn get_thumbnail(
|
||||
.thumbnail_path
|
||||
.ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?;
|
||||
let absolute = state.config.media_path.join(&rel);
|
||||
stream_media_file(&absolute, "image/jpeg".to_string(), "inline", "private, max-age=300").await
|
||||
stream_media_file(
|
||||
&absolute,
|
||||
"image/jpeg".to_string(),
|
||||
"inline",
|
||||
"private, max-age=300",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{delete, get, patch, post};
|
||||
use axum::Router;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
@@ -27,9 +27,10 @@ async fn main() -> Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
"eventsnap_backend=debug,tower_http=debug".into()
|
||||
}))
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "eventsnap_backend=debug,tower_http=debug".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
@@ -73,7 +74,10 @@ async fn main() -> Result<()> {
|
||||
.route("/api/v1/join", post(auth::handlers::join))
|
||||
.route("/api/v1/recover", post(auth::handlers::recover))
|
||||
// Forgotten-PIN escape hatch: ask a host to reset it (unauthenticated, throttled).
|
||||
.route("/api/v1/recover/request", post(auth::handlers::request_pin_reset))
|
||||
.route(
|
||||
"/api/v1/recover/request",
|
||||
post(auth::handlers::request_pin_reset),
|
||||
)
|
||||
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
|
||||
.route("/api/v1/session", delete(auth::handlers::logout))
|
||||
// "Sign out everywhere" — revoke all of the caller's sessions.
|
||||
@@ -83,8 +87,10 @@ async fn main() -> Result<()> {
|
||||
// layer just stops a multi-GB body from being buffered into memory before that
|
||||
// check runs. Sized generously above the default 500 MB video limit + multipart
|
||||
// overhead — if an admin raises max_video_size_mb above this, bump MAX_UPLOAD_BYTES.
|
||||
.route("/api/v1/upload", post(handlers::upload::upload)
|
||||
.route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)))
|
||||
.route(
|
||||
"/api/v1/upload",
|
||||
post(handlers::upload::upload).route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/upload/{id}",
|
||||
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
||||
@@ -112,24 +118,51 @@ async fn main() -> Result<()> {
|
||||
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
|
||||
.route("/api/v1/hashtags", get(handlers::feed::hashtags))
|
||||
// Social
|
||||
.route("/api/v1/upload/{id}/like", post(handlers::social::toggle_like))
|
||||
.route(
|
||||
"/api/v1/upload/{id}/like",
|
||||
post(handlers::social::toggle_like),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/upload/{id}/comments",
|
||||
get(handlers::social::list_comments).post(handlers::social::add_comment),
|
||||
)
|
||||
.route("/api/v1/comment/{id}", delete(handlers::social::delete_comment))
|
||||
.route(
|
||||
"/api/v1/comment/{id}",
|
||||
delete(handlers::social::delete_comment),
|
||||
)
|
||||
// SSE
|
||||
.route("/api/v1/stream", get(handlers::sse::stream))
|
||||
.route("/api/v1/stream/ticket", post(handlers::sse::issue_ticket))
|
||||
// Host Dashboard
|
||||
.route("/api/v1/host/event", get(handlers::host::get_event_status))
|
||||
.route("/api/v1/host/event/close", post(handlers::host::close_event))
|
||||
.route(
|
||||
"/api/v1/host/event/close",
|
||||
post(handlers::host::close_event),
|
||||
)
|
||||
.route("/api/v1/host/event/open", post(handlers::host::open_event))
|
||||
.route("/api/v1/host/gallery/release", post(handlers::host::release_gallery))
|
||||
.route(
|
||||
"/api/v1/host/gallery/release",
|
||||
post(handlers::host::release_gallery),
|
||||
)
|
||||
// Escape hatch: force a keepsake rebuild. Without it a failed export is terminal at runtime
|
||||
// (release_gallery refuses an already-released event; recovery only runs at boot).
|
||||
.route(
|
||||
"/api/v1/host/export/rebuild",
|
||||
post(handlers::host::rebuild_export),
|
||||
)
|
||||
.route("/api/v1/host/users", get(handlers::host::list_users))
|
||||
.route("/api/v1/host/users/{id}/ban", post(handlers::host::ban_user))
|
||||
.route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user))
|
||||
.route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role))
|
||||
.route(
|
||||
"/api/v1/host/users/{id}/ban",
|
||||
post(handlers::host::ban_user),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/host/users/{id}/unban",
|
||||
post(handlers::host::unban_user),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/host/users/{id}/role",
|
||||
patch(handlers::host::set_role),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/host/users/{id}/pin-reset",
|
||||
post(handlers::host::reset_user_pin),
|
||||
@@ -142,11 +175,20 @@ async fn main() -> Result<()> {
|
||||
"/api/v1/host/pin-reset-requests/{id}",
|
||||
delete(handlers::host::dismiss_pin_reset_request),
|
||||
)
|
||||
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload))
|
||||
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
|
||||
.route(
|
||||
"/api/v1/host/upload/{id}",
|
||||
delete(handlers::host::host_delete_upload),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/host/comment/{id}",
|
||||
delete(handlers::host::host_delete_comment),
|
||||
)
|
||||
// Export (all authenticated users)
|
||||
.route("/api/v1/export/status", get(handlers::admin::export_status))
|
||||
.route("/api/v1/export/ticket", post(handlers::admin::export_ticket))
|
||||
.route(
|
||||
"/api/v1/export/ticket",
|
||||
post(handlers::admin::export_ticket),
|
||||
)
|
||||
.route("/api/v1/export/zip", get(handlers::admin::download_zip))
|
||||
.route("/api/v1/export/html", get(handlers::admin::download_html))
|
||||
// Admin Dashboard
|
||||
@@ -155,7 +197,10 @@ async fn main() -> Result<()> {
|
||||
"/api/v1/admin/config",
|
||||
get(handlers::admin::get_config).patch(handlers::admin::patch_config),
|
||||
)
|
||||
.route("/api/v1/admin/export/jobs", get(handlers::admin::get_export_jobs));
|
||||
.route(
|
||||
"/api/v1/admin/export/jobs",
|
||||
get(handlers::admin::get_export_jobs),
|
||||
);
|
||||
|
||||
// Test-only route: a hard reset for the Playwright E2E harness. The handler
|
||||
// is compiled in always, but the route is only attached when
|
||||
|
||||
@@ -3,6 +3,10 @@ use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Row shape for `comment`: every field is populated by sqlx from `SELECT *` / `RETURNING *`.
|
||||
// `deleted_at` is not read in Rust today (the soft-delete filter lives in SQL), but it is part of
|
||||
// the row and stays here so the struct keeps mirroring the table.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct Comment {
|
||||
pub id: Uuid,
|
||||
@@ -79,26 +83,18 @@ impl Comment {
|
||||
}
|
||||
|
||||
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Self>(
|
||||
"SELECT * FROM comment WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE comment SET deleted_at = NOW() WHERE id = $1")
|
||||
sqlx::query_as::<_, Self>("SELECT * FROM comment WHERE id = $1 AND deleted_at IS NULL")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if the
|
||||
/// comment doesn't exist or belongs to a different event.
|
||||
/// Event-scoped soft delete. Returns `false` if the comment doesn't exist or belongs to a
|
||||
/// different event.
|
||||
/// Executor-generic so the delete and the keepsake regeneration can share one transaction
|
||||
/// (see `Upload::soft_delete_in_event` for why that must be atomic).
|
||||
pub async fn soft_delete_in_event(
|
||||
pool: &PgPool,
|
||||
conn: &mut sqlx::PgConnection,
|
||||
id: Uuid,
|
||||
event_id: Uuid,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
@@ -111,7 +107,7 @@ impl Comment {
|
||||
)
|
||||
.bind(id)
|
||||
.bind(event_id)
|
||||
.execute(pool)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Row shape for `event`: every field is populated by sqlx from `SELECT *` / `RETURNING *`. Several
|
||||
// (`slug`, `cover_image_path`, `export_epoch`, `created_at`) are not read through this struct today
|
||||
// — callers that need them query the column directly — but they are part of the row and stay here so
|
||||
// the struct keeps mirroring the table.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct Event {
|
||||
pub id: Uuid,
|
||||
@@ -11,8 +16,11 @@ pub struct Event {
|
||||
pub is_active: bool,
|
||||
pub uploads_locked_at: Option<DateTime<Utc>>,
|
||||
pub export_released_at: Option<DateTime<Utc>>,
|
||||
pub export_zip_ready: bool,
|
||||
pub export_html_ready: bool,
|
||||
/// Monotonic generation counter for the keepsake. Bumped in the SAME UPDATE as any change to
|
||||
/// `export_released_at` (release and reopen are its only writers). An export is downloadable
|
||||
/// iff a `done` `export_job` row carries this exact epoch — readiness is derived from that,
|
||||
/// never stored, so it cannot drift and no worker can resurrect it. See migration 014.
|
||||
pub export_epoch: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -25,13 +33,11 @@ impl Event {
|
||||
}
|
||||
|
||||
pub async fn create(pool: &PgPool, slug: &str, name: &str) -> Result<Self, sqlx::Error> {
|
||||
sqlx::query_as::<_, Self>(
|
||||
"INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
sqlx::query_as::<_, Self>("INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *")
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_or_create(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Row shape for `hashtag`, populated by sqlx from `RETURNING *` in `upsert`. Callers only use
|
||||
// `id` today; `event_id`/`tag` are the rest of the row and stay part of the struct.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct Hashtag {
|
||||
pub id: Uuid,
|
||||
@@ -61,22 +63,6 @@ impl Hashtag {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn tags_for_upload(
|
||||
pool: &PgPool,
|
||||
upload_id: Uuid,
|
||||
) -> Result<Vec<String>, sqlx::Error> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT h.tag FROM hashtag h
|
||||
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
|
||||
WHERE uh.upload_id = $1
|
||||
ORDER BY h.tag",
|
||||
)
|
||||
.bind(upload_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|r| r.0).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract `#hashtags` from text (caption or body). Tags are restricted to
|
||||
@@ -136,14 +122,20 @@ mod tests {
|
||||
assert_eq!(extract_hashtags(&format!("#{ok}")), vec![ok.clone()]);
|
||||
// 41+ chars → dropped entirely (not truncated).
|
||||
let too_long = "a".repeat(41);
|
||||
assert_eq!(extract_hashtags(&format!("#{too_long}")), Vec::<String>::new());
|
||||
assert_eq!(
|
||||
extract_hashtags(&format!("#{too_long}")),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_tags_are_returned_verbatim_not_deduplicated() {
|
||||
// Dedup is the DB's job (Hashtag::upsert ON CONFLICT); extraction returns each
|
||||
// occurrence so callers can count/link them independently. Case folds to lower.
|
||||
assert_eq!(extract_hashtags("#fun #Fun #fun!"), vec!["fun", "fun", "fun"]);
|
||||
assert_eq!(
|
||||
extract_hashtags("#fun #Fun #fun!"),
|
||||
vec!["fun", "fun", "fun"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,6 +2,9 @@ use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Row shape for `session`, populated by sqlx from `RETURNING *`. Session validation is done in SQL
|
||||
// (expiry/last-seen predicates), so no field is read in Rust — the struct is the row's shape.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct Session {
|
||||
pub id: Uuid,
|
||||
@@ -87,10 +90,7 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_by_token_hash(
|
||||
pool: &PgPool,
|
||||
token_hash: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
pub async fn delete_by_token_hash(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("DELETE FROM session WHERE token_hash = $1")
|
||||
.bind(token_hash)
|
||||
.execute(pool)
|
||||
|
||||
@@ -3,6 +3,10 @@ use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Row shape for `upload`: every field is populated by sqlx from `RETURNING *` in `create`. Callers
|
||||
// mostly use `id` and hand the rest to the compression/feed queries, so most fields are never read
|
||||
// through this struct — they stay here so it keeps mirroring the table.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct Upload {
|
||||
pub id: Uuid,
|
||||
@@ -75,15 +79,6 @@ impl Upload {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Self>(
|
||||
"SELECT * FROM upload WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Lean lookup for the public media aliases (`get_original`/`get_preview`/
|
||||
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
|
||||
/// excluding soft-deleted rows, hidden owners (`uploads_hidden`), and banned owners
|
||||
@@ -188,12 +183,17 @@ impl Upload {
|
||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if no row
|
||||
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
||||
/// can return a clean 404 instead of silently no-op'ing.
|
||||
/// Executor-generic so a caller can run the delete and the keepsake regeneration in ONE
|
||||
/// transaction. They must be atomic: if the delete commits and the regeneration doesn't (a
|
||||
/// dropped handler future, a failed second tx), the taken-down photo stays in the downloadable
|
||||
/// archive forever, and recovery can't tell — the keepsake still looks complete at the current
|
||||
/// epoch, and the host can no longer even find the upload to retry.
|
||||
pub async fn soft_delete_in_event(
|
||||
pool: &PgPool,
|
||||
conn: &mut sqlx::PgConnection,
|
||||
id: Uuid,
|
||||
event_id: Uuid,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let tx = conn;
|
||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
||||
"UPDATE upload
|
||||
SET deleted_at = NOW()
|
||||
@@ -218,7 +218,6 @@ impl Upload {
|
||||
} else {
|
||||
false
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ impl UserRole {
|
||||
}
|
||||
}
|
||||
|
||||
// Row shape for `user`: every field is populated by sqlx from `SELECT *` / `RETURNING *`.
|
||||
// `uploads_hidden`, `failed_pin_attempts` and `created_at` are enforced/updated in SQL rather than
|
||||
// read in Rust, but they are part of the row and stay here so the struct keeps mirroring the table.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
@@ -105,14 +109,16 @@ impl User {
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn lock_pin(pool: &PgPool, id: Uuid, until: DateTime<Utc>) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE \"user\" SET pin_locked_until = $2 WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(until)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
pub async fn lock_pin(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
until: DateTime<Utc>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE \"user\" SET pin_locked_until = $2 WHERE id = $1")
|
||||
.bind(id)
|
||||
.bind(until)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::{broadcast, Semaphore};
|
||||
use tokio::sync::{Semaphore, broadcast};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::upload::Upload;
|
||||
@@ -15,24 +16,54 @@ pub struct CompressionWorker {
|
||||
pool: PgPool,
|
||||
media_path: PathBuf,
|
||||
sse_tx: broadcast::Sender<SseEvent>,
|
||||
/// Bumped whenever the underlying data is reset out from under in-flight work (only the e2e
|
||||
/// TRUNCATE does this today). A task captures the value at spawn and abandons itself if it has
|
||||
/// changed by the time it runs — see `process`.
|
||||
generation: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl CompressionWorker {
|
||||
pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize, sse_tx: broadcast::Sender<SseEvent>) -> Self {
|
||||
pub fn new(
|
||||
pool: PgPool,
|
||||
media_path: PathBuf,
|
||||
concurrency: usize,
|
||||
sse_tx: broadcast::Sender<SseEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
semaphore: Arc::new(Semaphore::new(concurrency)),
|
||||
pool,
|
||||
media_path,
|
||||
sse_tx,
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate all in-flight and queued compression work. Called by the e2e TRUNCATE endpoint:
|
||||
/// truncating deletes the upload rows and wipes `media/`, so a worker that was queued on the
|
||||
/// semaphore when the wipe happened would otherwise wake in the NEXT test, fail to find its
|
||||
/// file, and broadcast `upload-error` / `upload-deleted` into that test's live SSE stream —
|
||||
/// corrupting any test that asserts on toasts or feed contents. Bumping the generation makes
|
||||
/// those stale tasks return silently instead. A no-op in production (never called there).
|
||||
pub fn bump_generation(&self) {
|
||||
self.generation.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Spawn a background task to process an uploaded file.
|
||||
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
|
||||
let worker = self.clone();
|
||||
let born_at = worker.generation.load(Ordering::SeqCst);
|
||||
tokio::spawn(async move {
|
||||
let _permit = worker.semaphore.acquire().await;
|
||||
match worker.do_process(upload_id, &original_path, &mime_type).await {
|
||||
// The data this task was queued against may have been reset while it waited for a permit
|
||||
// (e2e TRUNCATE). If so, its file and row are gone; doing anything — including
|
||||
// broadcasting a failure — would leak into an unrelated test. Abandon quietly.
|
||||
if worker.generation.load(Ordering::SeqCst) != born_at {
|
||||
return;
|
||||
}
|
||||
match worker
|
||||
.do_process(upload_id, &original_path, &mime_type)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
tracing::info!("compression completed for upload {upload_id}");
|
||||
let _ = worker.sse_tx.send(SseEvent {
|
||||
@@ -58,7 +89,8 @@ impl CompressionWorker {
|
||||
}
|
||||
let _ = worker.sse_tx.send(SseEvent {
|
||||
event_type: "upload-error".to_string(),
|
||||
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() }).to_string(),
|
||||
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() })
|
||||
.to_string(),
|
||||
});
|
||||
let _ = worker.sse_tx.send(SseEvent {
|
||||
event_type: "upload-deleted".to_string(),
|
||||
@@ -80,7 +112,9 @@ impl CompressionWorker {
|
||||
let original = self.media_path.join(original_path);
|
||||
|
||||
if mime_type.starts_with("image/") {
|
||||
let preview_rel = self.generate_image_preview(upload_id, &original, mime_type).await?;
|
||||
let preview_rel = self
|
||||
.generate_image_preview(upload_id, &original, mime_type)
|
||||
.await?;
|
||||
Upload::set_preview_path(&self.pool, upload_id, &preview_rel).await?;
|
||||
tracing::info!("preview generated for upload {upload_id}");
|
||||
} else if mime_type.starts_with("video/") {
|
||||
@@ -128,7 +162,8 @@ impl CompressionWorker {
|
||||
|
||||
// Resize to max 800px wide, preserving aspect ratio
|
||||
let preview = img.resize(800, 800, image::imageops::FilterType::Lanczos3);
|
||||
preview.save_with_format(&preview_path_clone, image::ImageFormat::Jpeg)
|
||||
preview
|
||||
.save_with_format(&preview_path_clone, image::ImageFormat::Jpeg)
|
||||
.context("failed to save preview")?;
|
||||
|
||||
// If the original is PNG, try lossless compression in-place
|
||||
@@ -151,11 +186,7 @@ impl CompressionWorker {
|
||||
Ok(format!("previews/{preview_filename}"))
|
||||
}
|
||||
|
||||
async fn generate_video_thumbnail(
|
||||
&self,
|
||||
upload_id: Uuid,
|
||||
original: &Path,
|
||||
) -> Result<String> {
|
||||
async fn generate_video_thumbnail(&self, upload_id: Uuid, original: &Path) -> Result<String> {
|
||||
let thumbs_dir = self.media_path.join("thumbnails");
|
||||
tokio::fs::create_dir_all(&thumbs_dir).await?;
|
||||
|
||||
@@ -185,18 +216,14 @@ impl CompressionWorker {
|
||||
.spawn()
|
||||
.context("failed to spawn ffmpeg")?;
|
||||
|
||||
let status = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(120),
|
||||
child.wait(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(res) => res.context("ffmpeg wait failed")?,
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
anyhow::bail!("ffmpeg timeout after 120s");
|
||||
}
|
||||
};
|
||||
let status =
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(120), child.wait()).await {
|
||||
Ok(res) => res.context("ffmpeg wait failed")?,
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
anyhow::bail!("ffmpeg timeout after 120s");
|
||||
}
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
// Best-effort: drain stderr for the log.
|
||||
@@ -205,10 +232,7 @@ impl CompressionWorker {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let _ = handle.read_to_end(&mut stderr).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"ffmpeg failed: {}",
|
||||
String::from_utf8_lossy(&stderr)
|
||||
);
|
||||
anyhow::bail!("ffmpeg failed: {}", String::from_utf8_lossy(&stderr));
|
||||
}
|
||||
|
||||
Ok(format!("thumbnails/{thumb_filename}"))
|
||||
|
||||
@@ -81,17 +81,18 @@ impl ConfigCache {
|
||||
}
|
||||
|
||||
// Cache miss or stale — reload the entire table in one query.
|
||||
let rows: Vec<(String, String)> =
|
||||
match sqlx::query_as::<_, (String, String)>("SELECT key, value FROM config")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "config reload failed; using defaults for this read");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let rows: Vec<(String, String)> = match sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT key, value FROM config",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "config reload failed; using defaults for this read");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let values: HashMap<String, String> = rows.into_iter().collect();
|
||||
let result = values.get(key).cloned();
|
||||
@@ -104,26 +105,43 @@ impl ConfigCache {
|
||||
}
|
||||
|
||||
pub async fn get_str(cache: &ConfigCache, key: &str, default: &str) -> String {
|
||||
cache.get_raw(key).await.unwrap_or_else(|| default.to_string())
|
||||
cache
|
||||
.get_raw(key)
|
||||
.await
|
||||
.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_i64(cache: &ConfigCache, key: &str, default: i64) -> i64 {
|
||||
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
cache
|
||||
.get_raw(key)
|
||||
.await
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
pub async fn get_usize(cache: &ConfigCache, key: &str, default: usize) -> usize {
|
||||
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
cache
|
||||
.get_raw(key)
|
||||
.await
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
pub async fn get_f64(cache: &ConfigCache, key: &str, default: f64) -> f64 {
|
||||
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
cache
|
||||
.get_raw(key)
|
||||
.await
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Parses common truthy spellings used by both the migration seeds and the admin form.
|
||||
/// Accepts `true/false`, `1/0`, `yes/no`, `on/off` — case-insensitive. Anything else
|
||||
/// returns `default`.
|
||||
pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool {
|
||||
let Some(raw) = cache.get_raw(key).await else { return default };
|
||||
let Some(raw) = cache.get_raw(key).await else {
|
||||
return default;
|
||||
};
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" | "yes" | "on" => true,
|
||||
"false" | "0" | "no" | "off" => false,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! barely moves. [`DiskCache`] refreshes it at most once per [`TTL`] and serves the
|
||||
//! rest from memory.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -24,7 +24,7 @@ pub struct DiskInfo {
|
||||
/// `AppState`.
|
||||
#[derive(Clone)]
|
||||
pub struct DiskCache {
|
||||
inner: Arc<RwLock<Option<(DiskInfo, Instant)>>>,
|
||||
inner: Arc<RwLock<Option<(PathBuf, DiskInfo, Instant)>>>,
|
||||
}
|
||||
|
||||
impl DiskCache {
|
||||
@@ -34,19 +34,38 @@ impl DiskCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the cached reading so the next `snapshot()` re-measures the filesystem.
|
||||
///
|
||||
/// Used by the e2e TRUNCATE endpoint. Truncating deletes every uploaded file, which materially
|
||||
/// changes free space — but the cached reading survives for up to the TTL, so the next test can
|
||||
/// compute a quota from the PREVIOUS test's disk. That matters now that the quota tests steer
|
||||
/// the per-user limit off `free_disk_bytes`: a stale reading makes the limit wrong and the test
|
||||
/// flaky, for reasons that have nothing to do with the code under test.
|
||||
pub fn invalidate(&self) {
|
||||
*self.inner.write().unwrap() = None;
|
||||
}
|
||||
|
||||
/// Cached `(total, free)` bytes for the filesystem that holds `media_path`.
|
||||
///
|
||||
/// Returns `None` when the mount can't be resolved — callers MUST treat that as
|
||||
/// "unknown", never "zero free". (The quota path in particular fails *open* on
|
||||
/// `None`: enforcing a 0-byte limit would lock every user out of uploading.)
|
||||
pub fn snapshot(&self, media_path: &Path) -> Option<DiskInfo> {
|
||||
if let Some((info, at)) = *self.inner.read().unwrap() {
|
||||
if at.elapsed() < TTL {
|
||||
return Some(info);
|
||||
}
|
||||
/// Cached free-space reading for `path`.
|
||||
///
|
||||
/// The cache is keyed BY PATH. It used to hold a single slot and ignore its argument on a hit,
|
||||
/// so it would happily return the media volume's numbers for any other path within the TTL.
|
||||
/// That was invisible only because every caller happened to pass `media_path` — the first
|
||||
/// caller to ask about a different volume (e.g. the exports volume, which is a separate mount)
|
||||
/// would have silently got the wrong filesystem's free space.
|
||||
pub fn snapshot(&self, path: &Path) -> Option<DiskInfo> {
|
||||
if let Some((cached_path, info, at)) = self.inner.read().unwrap().as_ref()
|
||||
&& cached_path == path
|
||||
&& at.elapsed() < TTL
|
||||
{
|
||||
return Some(*info);
|
||||
}
|
||||
let info = read_disk_for_path(media_path)?;
|
||||
*self.inner.write().unwrap() = Some((info, Instant::now()));
|
||||
let info = read_disk_for_path(path)?;
|
||||
*self.inner.write().unwrap() = Some((path.to_path_buf(), info, Instant::now()));
|
||||
Some(info)
|
||||
}
|
||||
}
|
||||
@@ -104,20 +123,14 @@ mod tests {
|
||||
#[test]
|
||||
fn picks_longest_matching_mount() {
|
||||
// Both "/" and "/media" prefix the path; the dedicated volume must win.
|
||||
let mounts = vec![
|
||||
("/".to_string(), 100, 40),
|
||||
("/media".to_string(), 200, 150),
|
||||
];
|
||||
let mounts = vec![("/".to_string(), 100, 40), ("/media".to_string(), 200, 150)];
|
||||
let d = select_disk(&mounts, "/media/originals/x.jpg").unwrap();
|
||||
assert_eq!((d.total, d.free), (200, 150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_root_when_no_specific_mount_matches() {
|
||||
let mounts = vec![
|
||||
("/".to_string(), 100, 40),
|
||||
("/media".to_string(), 200, 150),
|
||||
];
|
||||
let mounts = vec![("/".to_string(), 100, 40), ("/media".to_string(), 200, 150)];
|
||||
// "/var/lib" is only prefixed by "/".
|
||||
let d = select_disk(&mounts, "/var/lib/data").unwrap();
|
||||
assert_eq!((d.total, d.free), (100, 40));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,73 +0,0 @@
|
||||
//! Shared shape for long-running background work.
|
||||
//!
|
||||
//! Today's [`compression`](crate::services::compression) and [`export`](crate::services::export)
|
||||
//! pipelines each implement their own progress + SSE plumbing. They could converge on the
|
||||
//! trait sketched here so future jobs (analytics, archival, ...) plug into one progress
|
||||
//! pipeline.
|
||||
//!
|
||||
//! This module is intentionally a *sketch*: the existing services are not yet wired to
|
||||
//! it. The aim is to (a) document the convention so new jobs follow it, (b) make the
|
||||
//! refactor mechanical when someone is ready to do it. See `docs/IDEAS.md` —
|
||||
//! "Maintainability principles" — for the rationale.
|
||||
//!
|
||||
//! Example of an eventual implementor:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! struct ZipExport { event_id: Uuid, /* … */ }
|
||||
//!
|
||||
//! impl BackgroundJob for ZipExport {
|
||||
//! fn name(&self) -> &'static str { "zip-export" }
|
||||
//! async fn run(self, ctx: JobContext) -> Result<()> {
|
||||
//! for (i, item) in items.iter().enumerate() {
|
||||
//! ctx.report(percent(i, items.len())).await?;
|
||||
//! // … write to zip …
|
||||
//! }
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// Handle handed to a running job: reports progress and emits SSE events.
|
||||
///
|
||||
/// Wraps the existing SSE broadcaster and an optional `export_job` row. Implementors
|
||||
/// don't need to know about `state.sse_tx` directly — they call [`JobContext::report`]
|
||||
/// and get the same effect.
|
||||
pub struct JobContext {
|
||||
pub job_id: Option<uuid::Uuid>,
|
||||
pub event_kind: &'static str,
|
||||
pub sse_tx: tokio::sync::broadcast::Sender<crate::state::SseEvent>,
|
||||
pub pool: sqlx::PgPool,
|
||||
}
|
||||
|
||||
impl JobContext {
|
||||
/// Update progress (0..=100) and broadcast an SSE tick. Cheap to call often —
|
||||
/// rate-limit at the call site if a job emits at > 10 Hz.
|
||||
pub async fn report(&self, percent: u8) -> Result<()> {
|
||||
if let Some(job_id) = self.job_id {
|
||||
sqlx::query("UPDATE export_job SET progress_pct = $1 WHERE id = $2")
|
||||
.bind(percent as i16)
|
||||
.bind(job_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
let _ = self.sse_tx.send(crate::state::SseEvent::new(
|
||||
self.event_kind,
|
||||
serde_json::json!({ "progress_pct": percent }).to_string(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One unit of work that publishes progress through a [`JobContext`].
|
||||
///
|
||||
/// `run` consumes `self`; spawn with `tokio::spawn` at the caller. Errors propagate;
|
||||
/// the caller is responsible for mapping them to `export_job.error_message` or
|
||||
/// equivalent. Implementors stay small — the trait deliberately has no `cancel`
|
||||
/// or `pause`; we have not needed those yet.
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait BackgroundJob: Send + 'static {
|
||||
fn name(&self) -> &'static str;
|
||||
async fn run(self, ctx: JobContext) -> Result<()>;
|
||||
}
|
||||
@@ -49,6 +49,18 @@ pub async fn startup_recovery(pool: &PgPool) {
|
||||
// rejects an already-released event), so `export::recover_exports` re-spawns these
|
||||
// failed-but-released jobs from `main` once `AppState` exists (it needs the media
|
||||
// paths + SSE sender this fn doesn't have).
|
||||
//
|
||||
// SINGLE-INSTANCE ASSUMPTION: this sweep is unscoped — it reaps every `running` row, not just
|
||||
// this process's. That is correct for the supported deployment (one app container; see
|
||||
// docker-compose.yml), where no other process can own a job at boot. If EventSnap is ever run
|
||||
// with more than one replica, a booting replica would mark a peer's live workers `failed`.
|
||||
// This is NOT merely wasteful — do not assume the epoch model contains it. Recovery re-arms the
|
||||
// job at the SAME epoch, so the reaped-but-still-alive worker and the fresh one would BOTH hold
|
||||
// that epoch; the guards carry no owner/lease token, so they share the same artifact paths and
|
||||
// either can win the other's `finalize_job`. That can corrupt or delete the keepsake. Running
|
||||
// more than one replica therefore requires an owner/lease/heartbeat on export_job first. That
|
||||
// machinery is not worth building for a single-container app — so this is a documented
|
||||
// CONSTRAINT, not a hidden assumption: do not scale this service horizontally as-is.
|
||||
match sqlx::query(
|
||||
"UPDATE export_job
|
||||
SET status = 'failed',
|
||||
@@ -75,11 +87,7 @@ pub async fn startup_recovery(pool: &PgPool) {
|
||||
/// - drops expired SSE tickets (30s TTL but the map keeps the slot until pruned)
|
||||
///
|
||||
/// Cadence is 1h — fine for both jobs at our scale.
|
||||
pub fn spawn_periodic_tasks(
|
||||
pool: PgPool,
|
||||
rate_limiter: RateLimiter,
|
||||
sse_tickets: SseTicketStore,
|
||||
) {
|
||||
pub fn spawn_periodic_tasks(pool: PgPool, rate_limiter: RateLimiter, sse_tickets: SseTicketStore) {
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
||||
// Fire the first tick immediately, then hourly.
|
||||
|
||||
@@ -2,7 +2,6 @@ pub mod compression;
|
||||
pub mod config;
|
||||
pub mod disk;
|
||||
pub mod export;
|
||||
pub mod jobs;
|
||||
pub mod maintenance;
|
||||
pub mod rate_limiter;
|
||||
pub mod sse_tickets;
|
||||
|
||||
@@ -24,7 +24,12 @@ impl RateLimiter {
|
||||
|
||||
/// Returns `Ok(())` if allowed, `Err(retry_after_secs)` if rate-limited.
|
||||
/// `retry_after_secs` is how long until the oldest slot in the window expires.
|
||||
pub fn check_with_retry(&self, key: impl Into<String>, max: usize, window: Duration) -> Result<(), u64> {
|
||||
pub fn check_with_retry(
|
||||
&self,
|
||||
key: impl Into<String>,
|
||||
max: usize,
|
||||
window: Duration,
|
||||
) -> Result<(), u64> {
|
||||
let now = Instant::now();
|
||||
let key = key.into();
|
||||
let mut map = self.windows.lock().unwrap();
|
||||
@@ -120,15 +125,67 @@ mod tests {
|
||||
assert!(rl.check("k", 1, w));
|
||||
assert!(!rl.check("k", 1, w));
|
||||
std::thread::sleep(Duration::from_millis(55));
|
||||
assert!(rl.check("k", 1, w), "the slot should expire once the window passes");
|
||||
assert!(
|
||||
rl.check("k", 1, w),
|
||||
"the slot should expire once the window passes"
|
||||
);
|
||||
}
|
||||
|
||||
/// `retry_after` is not a "some number in range" — it is the time until the oldest slot
|
||||
/// in the window frees up, and it is surfaced to clients as the backoff they sleep for
|
||||
/// (see `upload-queue.ts`). Asserting only `(1..=60)` spans the entire reachable domain
|
||||
/// of a 60s window, so a hardcoded `Err(1)` would satisfy it while telling every client
|
||||
/// to hammer the server a second later. Pin the actual value.
|
||||
#[test]
|
||||
fn retry_after_is_the_remaining_window() {
|
||||
let rl = RateLimiter::new();
|
||||
|
||||
// The slot was consumed just now, so essentially the whole window remains.
|
||||
// `as_secs()` truncates the sub-second remainder, so a 30s window reports 29.
|
||||
let w30 = Duration::from_secs(30);
|
||||
assert!(rl.check_with_retry("a", 1, w30).is_ok());
|
||||
let a = rl.check_with_retry("a", 1, w30).unwrap_err();
|
||||
assert_eq!(a, 29, "retry_after must be the remaining window, got {a}");
|
||||
|
||||
// A different window must yield a different retry_after: no single constant can
|
||||
// satisfy both this and the assertion above.
|
||||
let w10 = Duration::from_secs(10);
|
||||
assert!(rl.check_with_retry("b", 1, w10).is_ok());
|
||||
let b = rl.check_with_retry("b", 1, w10).unwrap_err();
|
||||
assert_eq!(b, 9, "retry_after must scale with the window, got {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_is_between_one_and_window() {
|
||||
fn retry_after_counts_down_as_the_window_elapses() {
|
||||
let rl = RateLimiter::new();
|
||||
assert!(rl.check_with_retry("k", 1, MIN).is_ok());
|
||||
let retry = rl.check_with_retry("k", 1, MIN).unwrap_err();
|
||||
assert!((1..=60).contains(&retry), "retry_after {retry} out of range");
|
||||
let w = Duration::from_secs(30);
|
||||
assert!(rl.check_with_retry("k", 1, w).is_ok());
|
||||
let first = rl.check_with_retry("k", 1, w).unwrap_err();
|
||||
|
||||
std::thread::sleep(Duration::from_millis(1200));
|
||||
let second = rl.check_with_retry("k", 1, w).unwrap_err();
|
||||
|
||||
// A client that waits 1.2s must be told to wait ~1.2s less — otherwise the advertised
|
||||
// backoff is a constant, not a deadline.
|
||||
let shaved = first - second;
|
||||
assert!(
|
||||
(1..=2).contains(&shaved),
|
||||
"1.2s of waiting must shorten the advertised backoff by ~1s (got {first} then {second})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_floors_at_one_second() {
|
||||
let rl = RateLimiter::new();
|
||||
let w = Duration::from_millis(800);
|
||||
assert!(rl.check_with_retry("k", 1, w).is_ok());
|
||||
let retry = rl.check_with_retry("k", 1, w).unwrap_err();
|
||||
// The sub-second remainder truncates to 0; clients must never be told "retry in 0s"
|
||||
// (that's a busy-loop). The `.max(1)` floor is what prevents it.
|
||||
assert_eq!(
|
||||
retry, 1,
|
||||
"a sub-second remainder must floor to 1, got {retry}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -140,6 +197,59 @@ mod tests {
|
||||
assert!(rl.check("k", 1, MIN), "clear() must free the window");
|
||||
}
|
||||
|
||||
/// `prune()` is a memory-leak guard: without it a long-lived process keeps one HashMap
|
||||
/// entry per IP that ever connected. Nothing in the public API observes the map size, so
|
||||
/// the only way to catch a no-op body (`fn prune(&self) {}`) is to look at the map — the
|
||||
/// tests module can see the private field.
|
||||
#[test]
|
||||
fn prune_drops_keys_whose_windows_have_fully_expired() {
|
||||
let rl = RateLimiter::new();
|
||||
|
||||
// A key whose only timestamp is older than the 24h ceiling. We can't sleep for a day,
|
||||
// so backdate the Instant directly.
|
||||
let ancient = Instant::now()
|
||||
.checked_sub(Duration::from_secs(25 * 60 * 60))
|
||||
.expect("backdating an Instant by 25h");
|
||||
rl.windows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("stale".to_string(), vec![ancient]);
|
||||
|
||||
// ...alongside a key that is still inside its window.
|
||||
assert!(rl.check("live", 5, MIN));
|
||||
assert_eq!(rl.windows.lock().unwrap().len(), 2);
|
||||
|
||||
rl.prune();
|
||||
|
||||
let map = rl.windows.lock().unwrap();
|
||||
assert!(
|
||||
!map.contains_key("stale"),
|
||||
"prune() must drop keys whose timestamps have all expired"
|
||||
);
|
||||
assert!(
|
||||
map.contains_key("live"),
|
||||
"prune() must keep keys that still have live timestamps"
|
||||
);
|
||||
assert_eq!(map.len(), 1, "exactly one key should survive the prune");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_does_not_reset_a_live_window() {
|
||||
// The counterpart to the test above: pruning must reclaim memory, never quota. If
|
||||
// prune() dropped live keys, every background sweep would hand attackers a fresh
|
||||
// budget.
|
||||
let rl = RateLimiter::new();
|
||||
assert!(rl.check("k", 1, MIN));
|
||||
assert!(!rl.check("k", 1, MIN));
|
||||
|
||||
rl.prune();
|
||||
|
||||
assert!(
|
||||
!rl.check("k", 1, MIN),
|
||||
"prune() must not clear a window that is still active"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_ip_takes_rightmost_forwarded_for_entry() {
|
||||
// The right-most entry is the hop our trusted proxy (Caddy) appended.
|
||||
@@ -152,7 +262,10 @@ mod tests {
|
||||
fn client_ip_ignores_spoofed_leftmost_entry() {
|
||||
// A client prepending a fake IP to dodge throttles must not win.
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("x-forwarded-for", "1.2.3.4, 9.9.9.9, 203.0.113.7".parse().unwrap());
|
||||
h.insert(
|
||||
"x-forwarded-for",
|
||||
"1.2.3.4, 9.9.9.9, 203.0.113.7".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(client_ip(&h, "fallback"), "203.0.113.7");
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ impl SseTicketStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every outstanding ticket. Used by the e2e TRUNCATE endpoint: tickets are bound to a
|
||||
/// session token hash, and TRUNCATE deletes the sessions out from under them, so anything left
|
||||
/// here is a dangling reference to a user that no longer exists.
|
||||
pub fn clear(&self) {
|
||||
self.inner.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Mint a new ticket bound to the caller's session (identified by token hash).
|
||||
pub fn issue(&self, token_hash: String) -> String {
|
||||
let ticket = random_ticket();
|
||||
@@ -83,7 +90,11 @@ mod tests {
|
||||
let ticket = store.issue("hash-1".into());
|
||||
assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1"));
|
||||
// Single-use: a replay of the same ticket is rejected.
|
||||
assert_eq!(store.consume(&ticket), None, "a consumed ticket must not be reusable");
|
||||
assert_eq!(
|
||||
store.consume(&ticket),
|
||||
None,
|
||||
"a consumed ticket must not be reusable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -124,6 +135,10 @@ mod tests {
|
||||
.expect("host uptime should exceed the ticket TTL"),
|
||||
},
|
||||
);
|
||||
assert_eq!(store.consume(&stale), None, "an expired ticket must not authenticate");
|
||||
assert_eq!(
|
||||
store.consume(&stale),
|
||||
None,
|
||||
"an expired ticket must not authenticate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
257
backend/tests/common/mod.rs
Normal file
257
backend/tests/common/mod.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
//! Shared fixtures for the DB-backed integration tests.
|
||||
//!
|
||||
//! Every helper here executes SQL that is **character-for-character identical** to what `src/`
|
||||
//! actually runs (see the `// SRC:` markers). That is the whole point: a paraphrased query is a
|
||||
//! query nobody runs, and a test that passes against a paraphrase proves nothing about production.
|
||||
|
||||
#![allow(dead_code)] // each integration-test crate uses a different subset of these helpers
|
||||
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Insert a bare, unreleased event (epoch 0, uploads open).
|
||||
pub async fn seed_event(pool: &PgPool, slug: &str) -> Uuid {
|
||||
sqlx::query_scalar("INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING id")
|
||||
.bind(slug)
|
||||
.bind("Hochzeit")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed event")
|
||||
}
|
||||
|
||||
/// Insert a guest with a zeroed byte total.
|
||||
pub async fn seed_user(pool: &PgPool, event_id: Uuid, name: &str) -> Uuid {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash)
|
||||
VALUES ($1, $2, 'x') RETURNING id",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed user")
|
||||
}
|
||||
|
||||
/// SRC: `handlers/host.rs::release_gallery` — the claim + epoch bump, verbatim.
|
||||
/// Returns the POST-increment epoch, exactly as the handler consumes it.
|
||||
pub async fn release_gallery(pool: &PgPool, slug: &str) -> Option<i64> {
|
||||
let claimed: Option<(Uuid, String, i64)> = sqlx::query_as(
|
||||
"UPDATE event
|
||||
SET export_released_at = NOW(),
|
||||
uploads_locked_at = COALESCE(uploads_locked_at, NOW()),
|
||||
export_epoch = export_epoch + 1
|
||||
WHERE slug = $1 AND export_released_at IS NULL
|
||||
RETURNING id, name, export_epoch",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.expect("release_gallery");
|
||||
|
||||
if let Some((event_id, _, epoch)) = claimed {
|
||||
// The handler arms both jobs in the SAME transaction; for a single-connection fixture the
|
||||
// sequencing is equivalent.
|
||||
let mut conn = pool.acquire().await.expect("acquire");
|
||||
enqueue_types_at_epoch(&mut conn, event_id, epoch, &["zip", "html"]).await;
|
||||
Some(epoch)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// SRC: `handlers/host.rs::open_event` — the one statement that retires an entire generation.
|
||||
/// Returns rows affected.
|
||||
pub async fn open_event(pool: &PgPool, slug: &str) -> u64 {
|
||||
sqlx::query(
|
||||
"UPDATE event
|
||||
SET uploads_locked_at = NULL,
|
||||
export_released_at = NULL,
|
||||
export_epoch = export_epoch + 1
|
||||
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
|
||||
)
|
||||
.bind(slug)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("open_event")
|
||||
.rows_affected()
|
||||
}
|
||||
|
||||
/// SRC: `services/export.rs::enqueue_types_at_epoch` — verbatim upsert.
|
||||
pub async fn enqueue_types_at_epoch(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
event_id: Uuid,
|
||||
epoch: i64,
|
||||
types: &[&str],
|
||||
) {
|
||||
for export_type in types {
|
||||
sqlx::query(
|
||||
"INSERT INTO export_job (event_id, type, status, progress_pct, epoch)
|
||||
VALUES ($1, $2::export_type, 'pending', 0, $3)
|
||||
ON CONFLICT (event_id, type) DO UPDATE
|
||||
SET status = 'pending', progress_pct = 0, file_path = NULL,
|
||||
error_message = NULL, completed_at = NULL,
|
||||
epoch = EXCLUDED.epoch
|
||||
WHERE export_job.status <> 'done' OR export_job.epoch <> EXCLUDED.epoch",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.bind(epoch)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.expect("enqueue_types_at_epoch");
|
||||
}
|
||||
}
|
||||
|
||||
/// SRC: `services/export.rs::claim_job` — verbatim. `true` = we won the generation.
|
||||
pub async fn claim_job(pool: &PgPool, event_id: Uuid, export_type: &str, epoch: i64) -> bool {
|
||||
sqlx::query(
|
||||
"UPDATE export_job SET status = 'running'
|
||||
WHERE event_id = $1 AND type = $2::export_type
|
||||
AND epoch = $3 AND status = 'pending'",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.bind(epoch)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("claim_job")
|
||||
.rows_affected()
|
||||
> 0
|
||||
}
|
||||
|
||||
/// SRC: `services/export.rs::finalize_job` — verbatim. This IS the publish step.
|
||||
pub async fn finalize_job(
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
export_type: &str,
|
||||
epoch: i64,
|
||||
file_path: &str,
|
||||
) -> bool {
|
||||
sqlx::query(
|
||||
"UPDATE export_job
|
||||
SET status = 'done', progress_pct = 100, file_path = $3, completed_at = NOW()
|
||||
WHERE event_id = $1 AND type = $2::export_type
|
||||
AND epoch = $4 AND status = 'running'",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.bind(file_path)
|
||||
.bind(epoch)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("finalize_job")
|
||||
.rows_affected()
|
||||
> 0
|
||||
}
|
||||
|
||||
/// SRC: `services/export.rs::update_progress` — verbatim. Doubles as the worker's liveness check:
|
||||
/// `false` means "your generation was retired, stop working".
|
||||
pub async fn update_progress(
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
export_type: &str,
|
||||
epoch: i64,
|
||||
pct: i16,
|
||||
) -> bool {
|
||||
sqlx::query(
|
||||
"UPDATE export_job SET progress_pct = $3
|
||||
WHERE event_id = $1 AND type = $2::export_type
|
||||
AND epoch = $4 AND status = 'running'",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.bind(pct)
|
||||
.bind(epoch)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("update_progress")
|
||||
.rows_affected()
|
||||
> 0
|
||||
}
|
||||
|
||||
/// SRC: `services/export.rs::invalidate_and_arm` — the ViewerOnly ZIP carry-forward, verbatim.
|
||||
/// Returns `rows_affected() == 1`, which is what the production code branches on.
|
||||
pub async fn carry_zip_forward(pool: &PgPool, event_id: Uuid, epoch: i64) -> bool {
|
||||
sqlx::query(
|
||||
"UPDATE export_job SET epoch = $2
|
||||
WHERE event_id = $1 AND type = 'zip'::export_type
|
||||
AND status = 'done' AND epoch = $2 - 1",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(epoch)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("carry_zip_forward")
|
||||
.rows_affected()
|
||||
== 1
|
||||
}
|
||||
|
||||
/// SRC: `services/export.rs::invalidate_and_arm` — the epoch bump, verbatim.
|
||||
pub async fn bump_epoch(pool: &PgPool, slug: &str) -> Option<(Uuid, String, i64)> {
|
||||
sqlx::query_as(
|
||||
"UPDATE event SET export_epoch = export_epoch + 1
|
||||
WHERE slug = $1 AND export_released_at IS NOT NULL
|
||||
RETURNING id, name, export_epoch",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.expect("bump_epoch")
|
||||
}
|
||||
|
||||
/// The event's authoritative epoch.
|
||||
pub async fn event_epoch(pool: &PgPool, event_id: Uuid) -> i64 {
|
||||
sqlx::query_scalar("SELECT export_epoch FROM event WHERE id = $1")
|
||||
.bind(event_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("event_epoch")
|
||||
}
|
||||
|
||||
/// The raw job row, bypassing `export_current` — what the WORKER sees.
|
||||
pub async fn job_row(
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
export_type: &str,
|
||||
) -> Option<(String, i64, Option<String>)> {
|
||||
sqlx::query_as(
|
||||
"SELECT status::text, epoch, file_path FROM export_job
|
||||
WHERE event_id = $1 AND type = $2::export_type",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.expect("job_row")
|
||||
}
|
||||
|
||||
/// Does `export_current` expose this job at all? (The view itself, without the `status` filter —
|
||||
/// it is what `handlers/admin.rs::export_status` reports to the host UI.)
|
||||
pub async fn in_export_current(pool: &PgPool, event_id: Uuid, export_type: &str) -> bool {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM export_current
|
||||
WHERE event_id = $1 AND type = $2::export_type",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("in_export_current")
|
||||
> 0
|
||||
}
|
||||
|
||||
/// THE download predicate. SRC: `handlers/admin.rs::download_export` reads exactly this shape —
|
||||
/// `SELECT c.file_path FROM export_current c WHERE ... AND c.status = 'done'`. If this returns
|
||||
/// `Some`, a guest can download the keepsake; if `None`, they get a 404.
|
||||
pub async fn downloadable(pool: &PgPool, event_id: Uuid, export_type: &str) -> Option<String> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT c.file_path FROM export_current c
|
||||
WHERE c.event_id = $1 AND c.type = $2::export_type AND c.status = 'done'",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.expect("downloadable")
|
||||
.flatten()
|
||||
}
|
||||
435
backend/tests/export_epoch.rs
Normal file
435
backend/tests/export_epoch.rs
Normal file
@@ -0,0 +1,435 @@
|
||||
//! DB-backed integration tests for the export epoch state machine (migration 014).
|
||||
//!
|
||||
//! These run against a REAL Postgres: `#[sqlx::test]` creates a throwaway database per test and
|
||||
//! runs `backend/migrations/` into it, so the schema, the enums, the `UNIQUE (event_id, type)`
|
||||
//! constraint and the `export_current` view are the production ones — not a mock.
|
||||
//!
|
||||
//! THE INVARIANT, from migration 014:
|
||||
//!
|
||||
//! An export is downloadable IFF
|
||||
//! event.export_released_at IS NOT NULL
|
||||
//! AND export_job.epoch = event.export_epoch
|
||||
//! AND export_job.status = 'done'
|
||||
//!
|
||||
//! Readiness is DERIVED (the `export_current` view), never stored. Every test below pins one leg of
|
||||
//! that invariant with the exact SQL `src/` executes (see `tests/common/mod.rs`).
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use sqlx::PgPool;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Epoch monotonicity
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Release, reopen and re-release each bump `export_epoch`, and `RETURNING export_epoch` hands the
|
||||
/// caller the POST-increment value.
|
||||
///
|
||||
/// PREVENTS: a worker born with the PRE-increment epoch. It would be inert from the instant it
|
||||
/// started — every one of its writes is `epoch`-guarded, so `claim_job`/`finalize_job` would match
|
||||
/// nothing, the job row would sit at `pending` 0% forever with no live worker, and the host's
|
||||
/// download button would spin and then 404. The keepsake would never be built at all.
|
||||
#[sqlx::test]
|
||||
async fn release_returns_post_increment_epoch(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
assert_eq!(
|
||||
event_epoch(&pool, event_id).await,
|
||||
0,
|
||||
"a fresh event starts at epoch 0"
|
||||
);
|
||||
|
||||
let released = release_gallery(&pool, "wedding")
|
||||
.await
|
||||
.expect("release claims the event");
|
||||
assert_eq!(
|
||||
released, 1,
|
||||
"RETURNING must give the epoch AFTER the +1, not before"
|
||||
);
|
||||
assert_eq!(
|
||||
event_epoch(&pool, event_id).await,
|
||||
released,
|
||||
"worker's epoch == event's epoch"
|
||||
);
|
||||
|
||||
// The jobs armed by the release carry exactly that epoch — this is what makes the worker's
|
||||
// guarded writes match.
|
||||
for t in ["zip", "html"] {
|
||||
let (status, epoch, _) = job_row(&pool, event_id, t).await.expect("job armed");
|
||||
assert_eq!(status, "pending");
|
||||
assert_eq!(
|
||||
epoch, released,
|
||||
"{t} job must be armed at the epoch the worker was born with"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Epoch is strictly monotonic across the whole release/reopen/re-release cycle, and a second
|
||||
/// release attempt while already released is rejected WITHOUT bumping.
|
||||
///
|
||||
/// PREVENTS: epoch reuse. If a reopen could return the event to an epoch some old `done` row still
|
||||
/// carries, a retired keepsake — one that a guest asked to be taken down from — would silently
|
||||
/// become downloadable again.
|
||||
#[sqlx::test]
|
||||
async fn epoch_is_strictly_monotonic_across_reopen(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
|
||||
assert_eq!(release_gallery(&pool, "wedding").await, Some(1));
|
||||
|
||||
// A duplicate release is a no-op (`WHERE export_released_at IS NULL`) and must NOT bump.
|
||||
assert_eq!(
|
||||
release_gallery(&pool, "wedding").await,
|
||||
None,
|
||||
"already released"
|
||||
);
|
||||
assert_eq!(
|
||||
event_epoch(&pool, event_id).await,
|
||||
1,
|
||||
"a rejected release must not move the epoch"
|
||||
);
|
||||
|
||||
// Reopen retires the generation with ONE write.
|
||||
assert_eq!(open_event(&pool, "wedding").await, 1);
|
||||
assert_eq!(event_epoch(&pool, event_id).await, 2, "reopen bumps");
|
||||
|
||||
// And re-releasing bumps again — never back to 1.
|
||||
assert_eq!(
|
||||
release_gallery(&pool, "wedding").await,
|
||||
Some(3),
|
||||
"re-release bumps again"
|
||||
);
|
||||
assert_eq!(event_epoch(&pool, event_id).await, 3);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. A retired-epoch worker is inert
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A worker holding a retired epoch cannot write anything anybody can see: once the rows have been
|
||||
/// re-armed at a newer epoch, its `update_progress` and `finalize_job` both match 0 rows, and
|
||||
/// `export_current` never exposes its output.
|
||||
///
|
||||
/// PREVENTS: the classic lost race — a slow worker from BEFORE a takedown finishing afterwards and
|
||||
/// publishing an archive that still contains the photo a guest asked to have removed. "Please take
|
||||
/// my photo out" is the one request that most needs to reach the keepsake, and the keepsake is the
|
||||
/// artifact people keep forever.
|
||||
#[sqlx::test]
|
||||
async fn retired_epoch_worker_writes_are_no_ops(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
let old_epoch = release_gallery(&pool, "wedding").await.unwrap();
|
||||
|
||||
// Worker A is born at epoch 1 and claims the ZIP.
|
||||
assert!(
|
||||
claim_job(&pool, event_id, "zip", old_epoch).await,
|
||||
"worker A wins its claim"
|
||||
);
|
||||
assert!(
|
||||
update_progress(&pool, event_id, "zip", old_epoch, 40).await,
|
||||
"still live at 40%"
|
||||
);
|
||||
|
||||
// ── A takedown lands mid-export: `invalidate_and_arm(Affects::Both)` bumps and re-arms. ──
|
||||
let (_, _, new_epoch) = bump_epoch(&pool, "wedding")
|
||||
.await
|
||||
.expect("bump on a released event");
|
||||
assert_eq!(new_epoch, old_epoch + 1);
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
enqueue_types_at_epoch(&mut conn, event_id, new_epoch, &["zip", "html"]).await;
|
||||
drop(conn);
|
||||
|
||||
// Worker A is now INERT BY CONSTRUCTION. Every write is guarded on its own birth epoch.
|
||||
assert!(
|
||||
!update_progress(&pool, event_id, "zip", old_epoch, 90).await,
|
||||
"the liveness check must report `false` so worker A stops grinding through the gallery"
|
||||
);
|
||||
assert!(
|
||||
!finalize_job(&pool, event_id, "zip", old_epoch, "exports/Gallery.1.zip").await,
|
||||
"worker A's finalize MUST affect 0 rows — this is the write that would have published a \
|
||||
keepsake still containing the taken-down photo"
|
||||
);
|
||||
|
||||
// The re-armed row is untouched by the loser: still pending at the LIVE epoch, waiting for the
|
||||
// fresh worker. (If worker A had won, this row would read `done` at epoch 1.)
|
||||
let (status, epoch, file_path) = job_row(&pool, event_id, "zip").await.unwrap();
|
||||
assert_eq!((status.as_str(), epoch), ("pending", new_epoch));
|
||||
assert_eq!(
|
||||
file_path, None,
|
||||
"the loser's file_path must never be recorded"
|
||||
);
|
||||
|
||||
// And nothing is downloadable — not the stale archive, not anything.
|
||||
assert_eq!(downloadable(&pool, event_id, "zip").await, None);
|
||||
}
|
||||
|
||||
/// The documented, deliberate nuance in `claim_job`: after a bare `open_event` (which writes
|
||||
/// NOTHING to `export_job` — that is the point of the design), a worker at the old epoch still WINS
|
||||
/// its claim and can still write `done`. That is wasted work, not incorrectness: retirement is
|
||||
/// enforced at READ time. `export_current` must refuse to expose the row.
|
||||
///
|
||||
/// PREVENTS: someone "optimising" `claim_job` into a cross-table `EXISTS (SELECT ... FROM event)`
|
||||
/// guard — the exact unsound guard migration 014 removed (under READ COMMITTED, a blocked UPDATE
|
||||
/// re-evaluates same-row predicates but answers other-table subqueries from a stale snapshot).
|
||||
/// This test pins the read-time enforcement so the write-time guard is never re-added.
|
||||
#[sqlx::test]
|
||||
async fn reopen_retires_at_read_time_not_write_time(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
let epoch = release_gallery(&pool, "wedding").await.unwrap();
|
||||
|
||||
assert!(claim_job(&pool, event_id, "zip", epoch).await);
|
||||
|
||||
// Host reopens uploads. No export_job row is touched.
|
||||
assert_eq!(open_event(&pool, "wedding").await, 1);
|
||||
|
||||
// The in-flight worker's row-local writes still match — it was never told to stop.
|
||||
assert!(
|
||||
finalize_job(&pool, event_id, "zip", epoch, "exports/Gallery.1.zip").await,
|
||||
"documented: the claim/finalize is guarded on the JOB row's epoch, not the event's"
|
||||
);
|
||||
let (status, _, _) = job_row(&pool, event_id, "zip").await.unwrap();
|
||||
assert_eq!(status, "done", "the row really does say done");
|
||||
|
||||
// …and yet it is invisible. `export_current` requires the event to be released AND the epochs to
|
||||
// match; the reopen broke both. A worker at a dead epoch writes a row nobody can see.
|
||||
assert!(!in_export_current(&pool, event_id, "zip").await);
|
||||
assert_eq!(
|
||||
downloadable(&pool, event_id, "zip").await,
|
||||
None,
|
||||
"a reopened event must serve NO keepsake, however finished the job row looks"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. `export_current` exactness (table-driven)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The view is the ONE definition of "downloadable". Released + `done` + matching epoch ⇒ present;
|
||||
/// break ANY single leg ⇒ absent. Nothing else may make it appear or disappear.
|
||||
///
|
||||
/// PREVENTS, leg by leg:
|
||||
/// * `released` — serving a keepsake for an event whose uploads are still open, i.e. an archive
|
||||
/// missing every photo taken after the snapshot.
|
||||
/// * `done` — handing out a half-written ZIP (a corrupt keepsake, downloaded once, kept forever).
|
||||
/// * `epoch` — the retired-generation download: the 404-forever keepsake, or worse, the archive
|
||||
/// still containing content that was taken down.
|
||||
#[sqlx::test]
|
||||
async fn export_current_is_exactly_the_invariant(pool: PgPool) {
|
||||
// (name, released?, status, job epoch offset from the event epoch, expected visible)
|
||||
let cases: &[(&str, bool, &str, i64, bool)] = &[
|
||||
("released + done + current epoch", true, "done", 0, true),
|
||||
(
|
||||
"NOT released (done, epoch matches)",
|
||||
false,
|
||||
"done",
|
||||
0,
|
||||
false,
|
||||
),
|
||||
("NOT done: pending", true, "pending", 0, false),
|
||||
("NOT done: running", true, "running", 0, false),
|
||||
("NOT done: failed", true, "failed", 0, false),
|
||||
("stale epoch (done, released)", true, "done", -1, false),
|
||||
("future epoch (done, released)", true, "done", 1, false),
|
||||
(
|
||||
"migration-014 retired sentinel epoch -1",
|
||||
true,
|
||||
"done",
|
||||
-2,
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
for (i, (name, released, status, offset, expect_visible)) in cases.iter().enumerate() {
|
||||
let slug = format!("case{i}");
|
||||
let event_id = seed_event(&pool, &slug).await;
|
||||
|
||||
// Get the event to a known epoch (1) either by releasing it, or — for the unreleased case —
|
||||
// by releasing and reopening, which leaves it unreleased at a non-zero epoch.
|
||||
let event_epoch_now = if *released {
|
||||
release_gallery(&pool, &slug).await.unwrap()
|
||||
} else {
|
||||
release_gallery(&pool, &slug).await.unwrap();
|
||||
open_event(&pool, &slug).await;
|
||||
event_epoch(&pool, event_id).await
|
||||
};
|
||||
|
||||
// Plant a single ZIP job row in the exact state under test. `-2` encodes "the sentinel the
|
||||
// migration stamps on retired rows", which must never equal a non-negative event epoch.
|
||||
// (Clear the rows the release armed first — `UNIQUE (event_id, type)`.)
|
||||
sqlx::query("DELETE FROM export_job WHERE event_id = $1")
|
||||
.bind(event_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("clear armed jobs");
|
||||
|
||||
let job_epoch = if *offset == -2 {
|
||||
-1
|
||||
} else {
|
||||
event_epoch_now + offset
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO export_job (event_id, type, status, progress_pct, epoch, file_path)
|
||||
VALUES ($1, 'zip', $2::export_status, 100, $3, 'exports/Gallery.zip')",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(*status)
|
||||
.bind(job_epoch)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("plant job row");
|
||||
|
||||
let visible = downloadable(&pool, event_id, "zip").await.is_some();
|
||||
assert_eq!(
|
||||
visible, *expect_visible,
|
||||
"export_current exactness violated for case: {name} \
|
||||
(released={released}, status={status}, job_epoch={job_epoch}, event_epoch={event_epoch_now})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. The ViewerOnly ZIP carry-forward
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Branch A — the ZIP is `done` at the outgoing epoch: the carry-forward re-stamps it to the new
|
||||
/// epoch (rows_affected = 1), so only the HTML viewer is rebuilt and the finished ZIP stays
|
||||
/// downloadable throughout.
|
||||
///
|
||||
/// PREVENTS: rebuilding a multi-GB archive because someone deleted a comment. The ZIP holds media,
|
||||
/// not comments — a needless rebuild would 404 the photo download for minutes to change nothing
|
||||
/// inside it.
|
||||
#[sqlx::test]
|
||||
async fn viewer_only_carries_a_done_zip_forward(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
let e1 = release_gallery(&pool, "wedding").await.unwrap();
|
||||
|
||||
// Both halves finish at epoch 1 — the keepsake is live.
|
||||
for t in ["zip", "html"] {
|
||||
assert!(claim_job(&pool, event_id, t, e1).await);
|
||||
assert!(finalize_job(&pool, event_id, t, e1, &format!("exports/{t}.{e1}.zip")).await);
|
||||
}
|
||||
let zip_file = downloadable(&pool, event_id, "zip")
|
||||
.await
|
||||
.expect("zip is live");
|
||||
|
||||
// ── A comment is moderated: invalidate_and_arm(Affects::ViewerOnly). ──
|
||||
let (_, _, e2) = bump_epoch(&pool, "wedding").await.unwrap();
|
||||
let carried = carry_zip_forward(&pool, event_id, e2).await;
|
||||
assert!(
|
||||
carried,
|
||||
"a `done` ZIP at epoch-1 MUST be carried forward (rows_affected == 1)"
|
||||
);
|
||||
|
||||
// Only the viewer is re-armed…
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
enqueue_types_at_epoch(&mut conn, event_id, e2, &["html"]).await;
|
||||
drop(conn);
|
||||
|
||||
// …and the ZIP is STILL DOWNLOADABLE, at the new epoch, pointing at the same, unrenamed file.
|
||||
let (status, epoch, _) = job_row(&pool, event_id, "zip").await.unwrap();
|
||||
assert_eq!(
|
||||
(status.as_str(), epoch),
|
||||
("done", e2),
|
||||
"the ZIP row rode the epoch bump"
|
||||
);
|
||||
assert_eq!(
|
||||
downloadable(&pool, event_id, "zip").await,
|
||||
Some(zip_file),
|
||||
"the carried archive must never stop being served — same file, new epoch"
|
||||
);
|
||||
|
||||
// The viewer, meanwhile, is correctly retired and pending a rebuild.
|
||||
assert_eq!(job_row(&pool, event_id, "html").await.unwrap().0, "pending");
|
||||
assert_eq!(downloadable(&pool, event_id, "html").await, None);
|
||||
}
|
||||
|
||||
/// Branch B — THE BUG WE JUST FIXED. If the ZIP is still `pending`/`running` when the comment is
|
||||
/// moderated (which is MINUTES for a real multi-GB gallery, and deleting a comment right after
|
||||
/// release is an utterly ordinary thing to do), the carry-forward matches NOTHING
|
||||
/// (rows_affected = 0) — so the caller must NOT assume it carried, and must re-arm the ZIP too.
|
||||
///
|
||||
/// PREVENTS: the stranded ZIP. Blindly re-arming only the viewer would leave the ZIP row at the
|
||||
/// retired epoch; the in-flight worker then finishes and writes `done` at an epoch `export_current`
|
||||
/// no longer matches, nothing ever re-arms it, and `GET /export/zip` 404s FOREVER — a keepsake the
|
||||
/// couple paid for that simply never appears, short of a reboot.
|
||||
#[sqlx::test]
|
||||
async fn viewer_only_carry_forward_matches_nothing_when_zip_unfinished(pool: PgPool) {
|
||||
for zip_state in ["pending", "running"] {
|
||||
let slug = format!("wedding-{zip_state}");
|
||||
let event_id = seed_event(&pool, &slug).await;
|
||||
let e1 = release_gallery(&pool, &slug).await.unwrap();
|
||||
|
||||
// The ZIP worker is still going; only the viewer has finished.
|
||||
if zip_state == "running" {
|
||||
assert!(claim_job(&pool, event_id, "zip", e1).await);
|
||||
}
|
||||
assert!(claim_job(&pool, event_id, "html", e1).await);
|
||||
assert!(finalize_job(&pool, event_id, "html", e1, "exports/Memories.1.zip").await);
|
||||
|
||||
// ── The comment is moderated. ──
|
||||
let (_, _, e2) = bump_epoch(&pool, &slug).await.unwrap();
|
||||
let carried = carry_zip_forward(&pool, event_id, e2).await;
|
||||
|
||||
assert!(
|
||||
!carried,
|
||||
"a {zip_state} ZIP has nothing to carry forward — the UPDATE must affect 0 rows \
|
||||
(its `status = 'done'` predicate is the whole precondition)"
|
||||
);
|
||||
|
||||
// The carry-forward's OWN result decides. It didn't match ⇒ rebuild the ZIP as well.
|
||||
let types: &[&str] = if carried { &["html"] } else { &["zip", "html"] };
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
enqueue_types_at_epoch(&mut conn, event_id, e2, types).await;
|
||||
drop(conn);
|
||||
|
||||
// THE ASSERTION THAT WOULD HAVE CAUGHT THE BUG: the ZIP must not be stranded at the dead
|
||||
// epoch. It is re-armed at the live one, so a fresh worker will actually build it.
|
||||
let (status, epoch, _) = job_row(&pool, event_id, "zip").await.unwrap();
|
||||
assert_eq!(
|
||||
(status.as_str(), epoch),
|
||||
("pending", e2),
|
||||
"the unfinished ZIP MUST be re-armed at the new epoch, not left stranded at {e1}"
|
||||
);
|
||||
|
||||
// Even if the old in-flight worker now "finishes", it is inert and cannot resurrect itself.
|
||||
assert!(!finalize_job(&pool, event_id, "zip", e1, "exports/Gallery.1.zip").await);
|
||||
assert_eq!(downloadable(&pool, event_id, "zip").await, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// The re-arm upsert must never clobber the archive it just carried forward.
|
||||
///
|
||||
/// `enqueue_types_at_epoch`'s `WHERE export_job.status <> 'done' OR export_job.epoch <> EXCLUDED.epoch`
|
||||
/// is the "startup recovery must not clobber a good half" rule, expressed as the readiness predicate
|
||||
/// itself. PREVENTS: boot recovery resetting a perfectly good, downloadable ZIP back to `pending`
|
||||
/// and making the keepsake 404 while it needlessly rebuilds.
|
||||
#[sqlx::test]
|
||||
async fn enqueue_preserves_a_done_half_at_the_current_epoch(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
let e1 = release_gallery(&pool, "wedding").await.unwrap();
|
||||
|
||||
// The ZIP finished; the HTML worker was killed mid-flight (crash) and sits at `running`.
|
||||
assert!(claim_job(&pool, event_id, "zip", e1).await);
|
||||
assert!(finalize_job(&pool, event_id, "zip", e1, "exports/Gallery.1.zip").await);
|
||||
assert!(claim_job(&pool, event_id, "html", e1).await);
|
||||
|
||||
// Boot recovery re-arms both types at the SAME epoch.
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
enqueue_types_at_epoch(&mut conn, event_id, e1, &["zip", "html"]).await;
|
||||
drop(conn);
|
||||
|
||||
// The good half survives untouched…
|
||||
let (status, epoch, file_path) = job_row(&pool, event_id, "zip").await.unwrap();
|
||||
assert_eq!(
|
||||
(status.as_str(), epoch),
|
||||
("done", e1),
|
||||
"a done half at the live epoch is preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
file_path.as_deref(),
|
||||
Some("exports/Gallery.1.zip"),
|
||||
"file_path not nulled"
|
||||
);
|
||||
assert!(downloadable(&pool, event_id, "zip").await.is_some());
|
||||
|
||||
// …and only the missing half is re-armed.
|
||||
assert_eq!(job_row(&pool, event_id, "html").await.unwrap().0, "pending");
|
||||
}
|
||||
297
backend/tests/upload_concurrency.rs
Normal file
297
backend/tests/upload_concurrency.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
//! DB-backed integration tests for the two concurrency guards in the upload commit path
|
||||
//! (`handlers/upload.rs`). Both are SQL — a `FOR SHARE` row lock and an atomic compare-and-increment
|
||||
//! — and both are load-bearing for things a user can actually lose: a wedding photo, or the disk.
|
||||
//!
|
||||
//! `#[sqlx::test]` gives each test a fresh database with the real migrations applied.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use common::*;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// SRC: `handlers/upload.rs:313-322` — the guarded quota increment, verbatim.
|
||||
/// Returns `rows_affected()`; the handler aborts the whole upload tx when this is 0.
|
||||
async fn quota_inc(exec: impl sqlx::PgExecutor<'_>, user_id: Uuid, size: i64, limit: i64) -> u64 {
|
||||
sqlx::query(
|
||||
"UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2
|
||||
WHERE id = $1 AND total_upload_bytes + $2 <= $3",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(size)
|
||||
.bind(limit)
|
||||
.execute(exec)
|
||||
.await
|
||||
.expect("quota_inc")
|
||||
.rows_affected()
|
||||
}
|
||||
|
||||
async fn total_bytes(pool: &PgPool, user_id: Uuid) -> i64 {
|
||||
sqlx::query_scalar("SELECT total_upload_bytes FROM \"user\" WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("total_bytes")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 5. The atomic quota increment
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Two attempts sized off ONE stale snapshot, each of which "fits" on its own, cannot both commit.
|
||||
/// The predicate re-reads `total_upload_bytes` inside the UPDATE, so the second matches 0 rows.
|
||||
///
|
||||
/// PREVENTS: one guest filling the disk. The handler's pre-flight quota check runs BEFORE the body is
|
||||
/// streamed — minutes earlier, for a 500 MB video. If the commit trusted that snapshot, a guest could
|
||||
/// start N uploads that each individually fit under the limit and land all N, blowing straight through
|
||||
/// the quota and (in a 1 GB container) taking the event down for everyone.
|
||||
#[sqlx::test]
|
||||
async fn quota_two_attempts_from_one_stale_snapshot_cannot_both_commit(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
let user_id = seed_user(&pool, event_id, "Gierige Gudrun").await;
|
||||
|
||||
const LIMIT: i64 = 100;
|
||||
const SIZE: i64 = 60;
|
||||
|
||||
// THE STALE SNAPSHOT: the pre-flight check both uploads were admitted on.
|
||||
let snapshot = total_bytes(&pool, user_id).await;
|
||||
assert_eq!(snapshot, 0);
|
||||
// Each upload, judged against that snapshot alone, fits: 0 + 60 <= 100. Twice.
|
||||
assert!(snapshot + SIZE <= LIMIT);
|
||||
|
||||
assert_eq!(
|
||||
quota_inc(&pool, user_id, SIZE, LIMIT).await,
|
||||
1,
|
||||
"the first upload commits"
|
||||
);
|
||||
assert_eq!(
|
||||
quota_inc(&pool, user_id, SIZE, LIMIT).await,
|
||||
0,
|
||||
"the second MUST affect 0 rows — it was admitted on a snapshot that is now a lie \
|
||||
(60 + 60 = 120 > 100). rows_affected() == 0 is what makes the handler abort."
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
total_bytes(&pool, user_id).await,
|
||||
SIZE,
|
||||
"never 120 — the quota held"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same, but genuinely CONCURRENT: two transactions that both read `total = 0`, then both try to
|
||||
/// commit 60 bytes against a 100-byte limit. The second UPDATE blocks on the first's row lock and —
|
||||
/// because every predicate is on the ROW BEING UPDATED — Postgres re-evaluates it against the
|
||||
/// post-commit row (EPQ) rather than the statement's original snapshot. It matches nothing.
|
||||
///
|
||||
/// PREVENTS: exactly the same disk-filling overrun, on the path it actually happens — two uploads
|
||||
/// in flight at once, which is the normal case at a party.
|
||||
#[sqlx::test]
|
||||
async fn quota_guard_is_atomic_under_concurrent_transactions(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
let user_id = seed_user(&pool, event_id, "Gierige Gudrun").await;
|
||||
|
||||
const LIMIT: i64 = 100;
|
||||
const SIZE: i64 = 60;
|
||||
|
||||
let mut tx1 = pool.begin().await.unwrap();
|
||||
let mut tx2 = pool.begin().await.unwrap();
|
||||
|
||||
// Both transactions read the same snapshot and both would pass a naive `total + size <= limit`
|
||||
// check done in Rust.
|
||||
for tx in [&mut tx1, &mut tx2] {
|
||||
let seen: i64 = sqlx::query_scalar("SELECT total_upload_bytes FROM \"user\" WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(seen, 0, "both see an empty quota");
|
||||
}
|
||||
|
||||
// tx1 takes the row lock and commits.
|
||||
assert_eq!(quota_inc(&mut *tx1, user_id, SIZE, LIMIT).await, 1);
|
||||
tx1.commit().await.unwrap();
|
||||
|
||||
// tx2's UPDATE was written against the stale snapshot but is evaluated against the row as it
|
||||
// now stands.
|
||||
assert_eq!(
|
||||
quota_inc(&mut *tx2, user_id, SIZE, LIMIT).await,
|
||||
0,
|
||||
"the loser MUST see 0 rows affected — this is the entire quota guarantee"
|
||||
);
|
||||
tx2.rollback().await.unwrap();
|
||||
|
||||
assert_eq!(total_bytes(&pool, user_id).await, SIZE);
|
||||
|
||||
// And an upload that legitimately fits in what's left still succeeds — the guard rejects
|
||||
// overruns, not everything.
|
||||
assert_eq!(
|
||||
quota_inc(&pool, user_id, 40, LIMIT).await,
|
||||
1,
|
||||
"0 + 60 + 40 == 100, exactly at the limit"
|
||||
);
|
||||
assert_eq!(total_bytes(&pool, user_id).await, LIMIT);
|
||||
assert_eq!(
|
||||
quota_inc(&pool, user_id, 1, LIMIT).await,
|
||||
0,
|
||||
"and one byte more is refused"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 6. The `FOR SHARE` upload lock vs. the release
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// SRC: `handlers/upload.rs:297-303` — the in-transaction re-check under a row lock, verbatim.
|
||||
async fn lock_and_read_event(
|
||||
tx: &mut sqlx::PgConnection,
|
||||
event_id: Uuid,
|
||||
) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
|
||||
sqlx::query_as(
|
||||
"SELECT uploads_locked_at, export_released_at FROM event WHERE id = $1 FOR SHARE",
|
||||
)
|
||||
.bind(event_id)
|
||||
.fetch_one(tx)
|
||||
.await
|
||||
.expect("FOR SHARE re-check")
|
||||
}
|
||||
|
||||
/// THE GUARD AGAINST SILENT, PERMANENT PHOTO LOSS.
|
||||
///
|
||||
/// An upload holding `FOR SHARE` on the event row must BLOCK the `UPDATE event SET
|
||||
/// export_released_at = NOW()` in `release_gallery` until it commits. Either the upload commits first
|
||||
/// — and the release (hence the export snapshot) is strictly ordered after it, so the keepsake
|
||||
/// CONTAINS the photo — or the release commits first and the upload observes the lock and rejects
|
||||
/// (reversibly: the client keeps the blob and resumes after a reopen).
|
||||
///
|
||||
/// PREVENTS: the lost wedding photo. Without this serialization: a guest starts a 500 MB video, the
|
||||
/// pre-flight lock check passes, the host releases the gallery, the export workers snapshot the
|
||||
/// uploads table, and THEN the upload commits. The photo appears in the live feed but is missing from
|
||||
/// the downloaded keepsake, forever — nothing ever regenerates it and nobody ever notices.
|
||||
#[sqlx::test]
|
||||
async fn for_share_upload_lock_serializes_against_release(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
let user_id = seed_user(&pool, event_id, "Fotograf Fritz").await;
|
||||
|
||||
// ── The guest's upload transaction takes the share lock. ──
|
||||
let mut upload_tx = pool.begin().await.unwrap();
|
||||
let (locked, released) = lock_and_read_event(&mut upload_tx, event_id).await;
|
||||
assert!(
|
||||
locked.is_none() && released.is_none(),
|
||||
"uploads are open, so we proceed to commit"
|
||||
);
|
||||
|
||||
// ── Concurrently, the host hits "Galerie freigeben". ──
|
||||
let release_done = Arc::new(AtomicBool::new(false));
|
||||
let release_task = {
|
||||
let pool = pool.clone();
|
||||
let release_done = release_done.clone();
|
||||
tokio::spawn(async move {
|
||||
sqlx::query(
|
||||
"UPDATE event
|
||||
SET export_released_at = NOW(),
|
||||
uploads_locked_at = COALESCE(uploads_locked_at, NOW()),
|
||||
export_epoch = export_epoch + 1
|
||||
WHERE id = $1 AND export_released_at IS NULL",
|
||||
)
|
||||
.bind(event_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("release");
|
||||
release_done.store(true, Ordering::SeqCst);
|
||||
})
|
||||
};
|
||||
|
||||
// The release MUST be stuck behind our `FOR SHARE` row lock. (`FOR SHARE` conflicts with the
|
||||
// `FOR UPDATE` lock the UPDATE needs, so Postgres makes it wait — this is not a timing race,
|
||||
// it is a lock-conflict guarantee; the sleep only gives it every chance to wrongly proceed.)
|
||||
tokio::time::sleep(Duration::from_millis(750)).await;
|
||||
assert!(
|
||||
!release_done.load(Ordering::SeqCst),
|
||||
"the release MUST block while an upload holds FOR SHARE — if it can slip past, the export \
|
||||
snapshot is taken while a photo is still committing and that photo is lost forever"
|
||||
);
|
||||
|
||||
// The photo commits. It is now unambiguously part of the upload set.
|
||||
let upload_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes)
|
||||
VALUES ($1, $2, 'originals/wedding/x.jpg', 'image/jpeg', 1234) RETURNING id",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(user_id)
|
||||
.fetch_one(&mut *upload_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
upload_tx.commit().await.unwrap();
|
||||
|
||||
// Only now can the release proceed.
|
||||
tokio::time::timeout(Duration::from_secs(5), release_task)
|
||||
.await
|
||||
.expect("the release must unblock once the upload commits")
|
||||
.unwrap();
|
||||
|
||||
// THE PAYOFF: the export snapshot — the very query the ZIP worker runs — sees the photo. Order
|
||||
// enforced by the lock: upload commit < release < snapshot.
|
||||
let snapshot: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT u.id FROM upload u
|
||||
JOIN \"user\" usr ON usr.id = u.user_id
|
||||
WHERE u.event_id = $1 AND u.deleted_at IS NULL
|
||||
AND usr.uploads_hidden = FALSE AND usr.is_banned = FALSE",
|
||||
)
|
||||
.bind(event_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
snapshot,
|
||||
vec![upload_id],
|
||||
"the released keepsake CONTAINS the in-flight photo"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other side of the same lock: once the release has COMMITTED, the next upload's `FOR SHARE`
|
||||
/// re-read sees `export_released_at` set and the handler rejects it with `UploadsLocked`.
|
||||
///
|
||||
/// PREVENTS: the same lost photo, on the losing side of the race — a photo committing AFTER the
|
||||
/// export snapshot would be in the live feed but missing from the keepsake. Rejecting is the correct
|
||||
/// outcome, and it is reversible: `UploadsLocked` (not Forbidden) tells the client to keep the blob
|
||||
/// and resume when the host reopens.
|
||||
#[sqlx::test]
|
||||
async fn upload_after_release_commits_sees_the_lock_and_is_rejected(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
|
||||
// Before the release, the re-check passes.
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
let (locked, released) = lock_and_read_event(&mut tx, event_id).await;
|
||||
assert!(locked.is_none() && released.is_none());
|
||||
tx.rollback().await.unwrap();
|
||||
|
||||
assert_eq!(release_gallery(&pool, "wedding").await, Some(1));
|
||||
|
||||
// After it, the identical re-check sees the release and the handler bails out.
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
let (locked, released) = lock_and_read_event(&mut tx, event_id).await;
|
||||
assert!(
|
||||
released.is_some(),
|
||||
"the FOR SHARE re-read MUST observe the committed release"
|
||||
);
|
||||
assert!(
|
||||
locked.is_some(),
|
||||
"release locks uploads in the same statement (release ⇒ lock)"
|
||||
);
|
||||
tx.rollback().await.unwrap();
|
||||
|
||||
// And a reopen makes it uploadable again — the rejection was reversible, not terminal.
|
||||
assert_eq!(open_event(&pool, "wedding").await, 1);
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
let (locked, released) = lock_and_read_event(&mut tx, event_id).await;
|
||||
assert!(
|
||||
locked.is_none() && released.is_none(),
|
||||
"the guest can resume their upload"
|
||||
);
|
||||
tx.rollback().await.unwrap();
|
||||
}
|
||||
@@ -132,15 +132,20 @@ the Host can clean up later).
|
||||
- **Sperren** opens a confirmation modal. Banning **always hides** the user's existing
|
||||
uploads (a banned user's content is "gone" everywhere) — there is no opt-out. Submitting
|
||||
calls `POST /host/users/{id}/ban` (no body).
|
||||
- **Entsperren** lifts the ban.
|
||||
- **Host** promotes a guest to host.
|
||||
- **Degradieren** — visible on Host rows. A Host can demote *other* Hosts back to
|
||||
guest (planned). The button is hidden on the Host's own row to prevent self-lockout;
|
||||
only an Admin can demote themselves out of moderation. Admins see Degradieren on
|
||||
every Host row.
|
||||
- **PIN zurücksetzen** (planned) — generates a new PIN and shows it once in a modal.
|
||||
See journey §4. Hosts see this on Guest rows only; Admins see it on Guest + Host
|
||||
rows.
|
||||
- **Entsperren** lifts the ban. Same authority boundary as ban (below): a plain Host may
|
||||
only unban Guests; only an Admin may unban a Host.
|
||||
- **Host** promotes a guest to host (Hosts and Admins may do this).
|
||||
- **Degradieren** — demote a Host back to guest. **Only an Admin may change a Host's
|
||||
role.** A plain Host may *not* demote a peer Host: doing so would let them then ban or
|
||||
PIN-reset (→ `/recover` account-takeover) that ex-peer, since those guards key off the
|
||||
target's *current* role. So the backend rejects it (403) and the button is hidden for
|
||||
non-admin Hosts. Nobody may change their own role (self-lockout / self-escalation guard),
|
||||
and Admins are un-demotable and un-bannable by anyone. (This tightens an earlier
|
||||
"Hosts may demote other Hosts" design — see the F1 security fix.)
|
||||
- **Sperren / PIN zurücksetzen** — a plain Host may act on Guests only; an Admin may act on
|
||||
Guests + Hosts; nobody may act on an Admin. The buttons are hidden where they'd 403.
|
||||
PIN reset generates a new PIN, shows it once in a modal, and revokes the target's
|
||||
existing sessions (forcing re-auth with the new PIN). See journey §4.
|
||||
6. **Deleting content** — Host can delete any upload or comment via the moderation routes
|
||||
(`DELETE /host/upload/{id}`, `DELETE /host/comment/{id}`). On mobile this is also
|
||||
reachable by long-pressing the content (planned, see §15).
|
||||
@@ -162,7 +167,11 @@ the Host can clean up later).
|
||||
5. Banning **always hides**: the user's existing uploads are filtered out of the feed for
|
||||
everyone (`v_feed`, `find_visible_media`, and the export query all enforce
|
||||
`is_banned = FALSE`), and a live `user-hidden` SSE event evicts their cards from every
|
||||
open feed + the diashow without a reload.
|
||||
open feed + the diashow without a reload. A client that was offline/disconnected during
|
||||
the live event doesn't miss the eviction: `uploads_hidden_at` is stamped at ban time
|
||||
(migration 013) and the reconnect delta (`GET /feed/delta`) returns the banned users in
|
||||
`hidden_user_ids`, so the feed and diashow replay the eviction on the next reconnect —
|
||||
the projector-missed-the-live-push case is exactly why this exists.
|
||||
|
||||
## 11. Admin — instance configuration
|
||||
|
||||
@@ -184,7 +193,18 @@ the Host can clean up later).
|
||||
## 12. Releasing the export and downloading
|
||||
|
||||
1. Host (or Admin) taps **Galerie freigeben** in the dashboard.
|
||||
2. Server sets `event.export_released_at` and enqueues two background jobs.
|
||||
2. Server sets `event.export_released_at`, locks uploads, **bumps `event.export_epoch`**, and
|
||||
enqueues two background jobs — all in ONE transaction (workers spawn after it commits, so a
|
||||
client disconnecting mid-request can never leave the event released with no export to build).
|
||||
|
||||
The **epoch** is the whole generation model (migration 014). It is bumped in the same UPDATE as
|
||||
any change to `export_released_at` — release and reopen are its only writers — and each
|
||||
`export_job` row carries a copy of the epoch it was enqueued for. An export is downloadable
|
||||
**iff** `released AND job.epoch = event.export_epoch AND job.status = 'done'`. Readiness is
|
||||
therefore *derived*, never stored: it cannot drift, and a worker whose epoch has been retired
|
||||
(by a reopen, a re-release, or a takedown) is inert — anything it writes is simply invisible.
|
||||
A reopen retires the current keepsake instantly, which is why a reopened event serves no export
|
||||
until the host releases again.
|
||||
3. ZIP job: streams `Gallery.zip` (`Photos/` + `Videos/`, full-quality originals) directly
|
||||
to disk via `async-zip`. Progress updates via `export-progress` SSE.
|
||||
4. HTML-viewer job: copies the pre-built viewer assets from
|
||||
@@ -192,6 +212,11 @@ the Host can clean up later).
|
||||
`include_dir!`), generates `data.json` from the database, processes `_thumb`/`_full`
|
||||
variants for each upload, and assembles `Memories.zip`.
|
||||
5. Both jobs complete → server broadcasts `export-available` SSE.
|
||||
**Takedowns:** if a host deletes an upload (or a comment) while the gallery is released, the
|
||||
epoch is bumped and the keepsake is REGENERATED without it. Otherwise a photo removed on request
|
||||
would live on forever in the already-generated archive — the one place it most needs to be gone.
|
||||
The download 404s for the few seconds it takes to rebuild, which is the correct answer: serving
|
||||
the old archive would serve the deleted photo.
|
||||
6. Any user opens `/export`:
|
||||
- Before release: friendly "Export not yet available" banner.
|
||||
- During generation: progress bars per artifact.
|
||||
|
||||
4
e2e/.prettierignore
Normal file
4
e2e/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
package-lock.json
|
||||
7
e2e/.prettierrc
Normal file
7
e2e/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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.
|
||||
@@ -47,25 +48,25 @@ The CI workflow at `.github/workflows/e2e.yml` runs both jobs on every PR.
|
||||
Every spec covers a journey from [`docs/USER_JOURNEYS.md`](../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. |
|
||||
| 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.
|
||||
- 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.
|
||||
@@ -104,17 +105,17 @@ 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. |
|
||||
| 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
|
||||
@@ -127,14 +128,14 @@ 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
|
||||
- **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 →
|
||||
- **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 in
|
||||
`docs/samsung-emulator.md` (to be written).
|
||||
- **Tier C** *(paid, optional)*: BrowserStack or LambdaTest cloud devices.
|
||||
- **Tier C** _(paid, optional)_: BrowserStack or LambdaTest cloud devices.
|
||||
Real Galaxy S22/S23 hardware via Playwright's cloud integration.
|
||||
|
||||
## Test isolation
|
||||
@@ -223,7 +224,7 @@ Known findings surfaced (documented in tests, not silent failures):
|
||||
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.
|
||||
- CSP on `/media/*` if SVGs are ever expected as user content.
|
||||
|
||||
### Phase 3 — Mobile gestures (`specs/09-mobile/`) ✅ landed
|
||||
|
||||
@@ -273,6 +274,7 @@ ignores this folder via `testIgnore` in [playwright.config.ts](playwright.config
|
||||
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.
|
||||
@@ -282,6 +284,7 @@ ignores this folder via `testIgnore` in [playwright.config.ts](playwright.config
|
||||
- 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.
|
||||
|
||||
@@ -17,12 +17,12 @@ services:
|
||||
POSTGRES_PASSWORD: eventsnap_test
|
||||
POSTGRES_DB: eventsnap_test
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U eventsnap_test -d eventsnap_test"]
|
||||
test: ['CMD-SHELL', 'pg_isready -U eventsnap_test -d eventsnap_test']
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
ports:
|
||||
- "55432:5432" # exposed so the e2e harness can connect via pg for fixture setup
|
||||
- '55432:5432' # exposed so the e2e harness can connect via pg for fixture setup
|
||||
|
||||
app:
|
||||
build:
|
||||
@@ -40,15 +40,15 @@ services:
|
||||
ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
|
||||
EVENT_SLUG: e2e-test-event
|
||||
EVENT_NAME: E2E Test Event
|
||||
APP_PORT: "3000"
|
||||
APP_PORT: '3000'
|
||||
MEDIA_PATH: /media
|
||||
SESSION_EXPIRY_DAYS: "30"
|
||||
EVENTSNAP_TEST_MODE: "1" # ENABLES /admin/__truncate — never set in prod
|
||||
SESSION_EXPIRY_DAYS: '30'
|
||||
EVENTSNAP_TEST_MODE: '1' # ENABLES /admin/__truncate — never set in prod
|
||||
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
||||
volumes:
|
||||
- media_data:/media
|
||||
expose:
|
||||
- "3000"
|
||||
- '3000'
|
||||
|
||||
frontend:
|
||||
build:
|
||||
@@ -57,11 +57,11 @@ services:
|
||||
depends_on:
|
||||
- app
|
||||
environment:
|
||||
PORT: "3001"
|
||||
HOST: "0.0.0.0"
|
||||
ORIGIN: "http://localhost:3101"
|
||||
PORT: '3001'
|
||||
HOST: '0.0.0.0'
|
||||
ORIGIN: 'http://localhost:3101'
|
||||
expose:
|
||||
- "3001"
|
||||
- '3001'
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
volumes:
|
||||
- ./Caddyfile.test:/etc/caddy/Caddyfile:ro
|
||||
ports:
|
||||
- "3101:3101"
|
||||
- '3101:3101'
|
||||
|
||||
volumes:
|
||||
media_data:
|
||||
|
||||
47
e2e/eslint.config.js
Normal file
47
e2e/eslint.config.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import js from '@eslint/js';
|
||||
import ts from 'typescript-eslint';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import globals from 'globals';
|
||||
|
||||
/**
|
||||
* Flat-config ESLint for the Playwright TypeScript suite. Prettier owns formatting (its config is
|
||||
* last so it disables every stylistic rule). The rules kept here catch real test bugs — unused
|
||||
* setup, floating promises that make a test race, `any` that hides a wrong assertion shape.
|
||||
*/
|
||||
export default ts.config(
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
// A floating promise in a test is a real hazard: an un-awaited request or assertion can let
|
||||
// the test end before it runs, passing vacuously. Keep it on.
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
// Off for the suite: this is all test code, where `any` is the honest type for an untyped
|
||||
// `res.json()` body or a `page.evaluate()` return. Threading DTO types through every
|
||||
// assertion is churn that buys nothing — the assertion values are what's checked, not the
|
||||
// static shape. (no-floating-promises and no-unused-vars, which catch real test bugs, stay on.)
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
// Off: Playwright fixtures with no dependencies are declared `async ({}, use) => {}` — the
|
||||
// empty destructure is required by the fixtures API, not an accident.
|
||||
'no-empty-pattern': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: { projectService: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['node_modules/', 'playwright-report/', 'test-results/', '*.config.js'],
|
||||
}
|
||||
);
|
||||
@@ -47,7 +47,9 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────────────
|
||||
async join(displayName: string): Promise<{ jwt: string; pin: string; user_id: string; is_new: boolean }> {
|
||||
async join(
|
||||
displayName: string
|
||||
): Promise<{ jwt: string; pin: string; user_id: string; is_new: boolean }> {
|
||||
const { body } = await this.request<any>('POST', '/join', {
|
||||
body: { display_name: displayName },
|
||||
expectedStatus: [201],
|
||||
@@ -55,7 +57,11 @@ export class ApiClient {
|
||||
return body;
|
||||
}
|
||||
|
||||
async recover(displayName: string, pin: string, opts: { expectedStatus?: number | number[] } = {}) {
|
||||
async recover(
|
||||
displayName: string,
|
||||
pin: string,
|
||||
opts: { expectedStatus?: number | number[] } = {}
|
||||
) {
|
||||
return this.request<any>('POST', '/recover', {
|
||||
body: { display_name: displayName, pin },
|
||||
expectedStatus: opts.expectedStatus ?? [200],
|
||||
@@ -91,7 +97,9 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getConfig(adminToken: string): Promise<Record<string, string>> {
|
||||
const { body } = await this.request<Record<string, string>>('GET', '/admin/config', { token: adminToken });
|
||||
const { body } = await this.request<Record<string, string>>('GET', '/admin/config', {
|
||||
token: adminToken,
|
||||
});
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -109,15 +117,20 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async banUser(token: string, userId: string, hideUploads = false) {
|
||||
// A ban ALWAYS hides the user's uploads — the backend takes no body and ignores any
|
||||
// `hide_uploads` flag (the old opt-out was removed). No per-request options.
|
||||
async banUser(token: string, userId: string) {
|
||||
return this.request<void>('POST', `/host/users/${userId}/ban`, {
|
||||
token,
|
||||
body: { hide_uploads: hideUploads },
|
||||
expectedStatus: [200, 204],
|
||||
});
|
||||
}
|
||||
|
||||
async unbanUser(token: string, userId: string, opts: { expectedStatus?: number | number[] } = {}) {
|
||||
async unbanUser(
|
||||
token: string,
|
||||
userId: string,
|
||||
opts: { expectedStatus?: number | number[] } = {}
|
||||
) {
|
||||
return this.request<void>('POST', `/host/users/${userId}/unban`, {
|
||||
token,
|
||||
expectedStatus: opts.expectedStatus ?? [200, 204],
|
||||
@@ -136,6 +149,11 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listPinResetRequests(token: string): Promise<any[]> {
|
||||
const { body } = await this.request<any[]>('GET', '/host/pin-reset-requests', { token });
|
||||
return body;
|
||||
}
|
||||
|
||||
async closeEvent(token: string) {
|
||||
return this.request<void>('POST', '/host/event/close', { token, expectedStatus: [200, 204] });
|
||||
}
|
||||
|
||||
@@ -39,11 +39,16 @@ export const db = {
|
||||
|
||||
async expireSession(userId: string) {
|
||||
await withClient((c) =>
|
||||
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [userId])
|
||||
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [
|
||||
userId,
|
||||
])
|
||||
);
|
||||
},
|
||||
|
||||
async setUploadCompressionStatus(uploadId: string, status: 'pending' | 'processing' | 'done' | 'failed') {
|
||||
async setUploadCompressionStatus(
|
||||
uploadId: string,
|
||||
status: 'pending' | 'processing' | 'done' | 'failed'
|
||||
) {
|
||||
await withClient((c) =>
|
||||
c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
|
||||
);
|
||||
@@ -59,6 +64,26 @@ export const db = {
|
||||
});
|
||||
},
|
||||
|
||||
async countSessionsForUser(userId: string): Promise<number> {
|
||||
return withClient(async (c) => {
|
||||
const r = await c.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count FROM session WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
return Number(r.rows[0].count);
|
||||
});
|
||||
},
|
||||
|
||||
async countPinResetRequestsForUser(userId: string): Promise<number> {
|
||||
return withClient(async (c) => {
|
||||
const r = await c.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count FROM pin_reset_request WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
return Number(r.rows[0].count);
|
||||
});
|
||||
},
|
||||
|
||||
async setExportReleased(slug: string, released: boolean) {
|
||||
await withClient((c) =>
|
||||
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
|
||||
@@ -69,26 +94,55 @@ export const db = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Flip the `export_zip_ready` gate directly. The download handler serves bytes
|
||||
* only when this boolean is true AND the file exists on disk, so setting it true
|
||||
* without a file lets tests exercise the "ready but file missing" 404 branch.
|
||||
* Make an export "ready" (or not) in the epoch model. There is no `export_zip_ready` column any
|
||||
* more — readiness is DERIVED (`released AND job.epoch = event.export_epoch AND status='done'`),
|
||||
* so a job is ready exactly when its row carries the event's live epoch. To make a `done` job NOT
|
||||
* ready we retire it to a dead epoch (-1), which is what a reopen effectively does.
|
||||
*
|
||||
* `file_path` is deliberately left NULL, so a "ready" job with no file on disk still exercises
|
||||
* the download's missing-file 404 branch.
|
||||
*/
|
||||
async setExportZipReady(slug: string, ready: boolean) {
|
||||
await withClient((c) =>
|
||||
c.query(`UPDATE event SET export_zip_ready = $2 WHERE slug = $1`, [slug, ready])
|
||||
c.query(
|
||||
`UPDATE export_job ej
|
||||
SET epoch = CASE WHEN $2 THEN e.export_epoch ELSE -1 END
|
||||
FROM event e
|
||||
WHERE e.id = ej.event_id AND e.slug = $1 AND ej.type = 'zip'`,
|
||||
[slug, ready]
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
/** Insert a pre-baked export job row to skip the (slow) real compression path. */
|
||||
async fakeExportJob(eventSlug: string, type: 'zip' | 'html', status: 'pending' | 'running' | 'done') {
|
||||
/**
|
||||
* Insert a pre-baked export job row to skip the (slow) real compression path. Stamped with the
|
||||
* event's CURRENT epoch so it counts as the live generation.
|
||||
*/
|
||||
async fakeExportJob(
|
||||
eventSlug: string,
|
||||
type: 'zip' | 'html',
|
||||
status: 'pending' | 'running' | 'done'
|
||||
) {
|
||||
await withClient(async (c) => {
|
||||
const ev = await c.query<{ id: string }>(`SELECT id FROM event WHERE slug = $1`, [eventSlug]);
|
||||
const ev = await c.query<{ id: string; export_epoch: string }>(
|
||||
`SELECT id, export_epoch FROM event WHERE slug = $1`,
|
||||
[eventSlug]
|
||||
);
|
||||
if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`);
|
||||
await c.query(
|
||||
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at)
|
||||
VALUES ($1, $2::export_type, $3::export_status, $4, $5)
|
||||
ON CONFLICT (event_id, type) DO UPDATE SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct`,
|
||||
[ev.rows[0].id, type, status, status === 'done' ? 100 : 0, status === 'done' ? new Date() : null]
|
||||
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at, epoch)
|
||||
VALUES ($1, $2::export_type, $3::export_status, $4, $5, $6)
|
||||
ON CONFLICT (event_id, type) DO UPDATE
|
||||
SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct,
|
||||
epoch = EXCLUDED.epoch`,
|
||||
[
|
||||
ev.rows[0].id,
|
||||
type,
|
||||
status,
|
||||
status === 'done' ? 100 : 0,
|
||||
status === 'done' ? new Date() : null,
|
||||
ev.rows[0].export_epoch,
|
||||
]
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -84,16 +84,13 @@ export const test = base.extend<Fixtures>({
|
||||
const fn = async (page: Page, handle: GuestHandle) => {
|
||||
// Visit any in-app URL first so localStorage is scoped to the right origin.
|
||||
await page.goto('/');
|
||||
await page.evaluate(
|
||||
({ jwt, pin, userId, displayName }) => {
|
||||
localStorage.setItem('eventsnap_jwt', jwt);
|
||||
localStorage.setItem('eventsnap_pin', pin);
|
||||
localStorage.setItem('eventsnap_user_id', userId);
|
||||
localStorage.setItem('eventsnap_display_name', displayName);
|
||||
localStorage.setItem('eventsnap_guide_seen', 'true');
|
||||
},
|
||||
handle
|
||||
);
|
||||
await page.evaluate(({ jwt, pin, userId, displayName }) => {
|
||||
localStorage.setItem('eventsnap_jwt', jwt);
|
||||
localStorage.setItem('eventsnap_pin', pin);
|
||||
localStorage.setItem('eventsnap_user_id', userId);
|
||||
localStorage.setItem('eventsnap_display_name', displayName);
|
||||
localStorage.setItem('eventsnap_guide_seen', 'true');
|
||||
}, handle);
|
||||
await page.goto('/feed');
|
||||
};
|
||||
await use(fn);
|
||||
|
||||
5
e2e/helpers/env.ts
Normal file
5
e2e/helpers/env.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Shared test environment constants. The frontend base URL was redeclared verbatim in ~23 specs;
|
||||
* one source of truth means a port/scheme change is a single edit, not a sweep.
|
||||
*/
|
||||
export const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
@@ -7,8 +7,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { uploadRaw } from './upload-client';
|
||||
import { db } from '../fixtures/db';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from './env';
|
||||
|
||||
// A real, decodable JPEG. Magic-bytes-only fakes now get auto-cleaned by the
|
||||
// backend when compression fails (a failed transcode refunds quota + deletes the
|
||||
|
||||
@@ -30,7 +30,8 @@ export class SseListener {
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
(async () => {
|
||||
// Fire-and-forget reader loop; the caller drives lifecycle via stop()/the AbortController.
|
||||
void (async () => {
|
||||
try {
|
||||
while (!this.closed) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -61,7 +62,9 @@ export class SseListener {
|
||||
if (hit) return hit;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error(`SSE event ${type} did not arrive within ${timeoutMs}ms. Got: ${this.events.map((e) => e.type).join(', ')}`);
|
||||
throw new Error(
|
||||
`SSE event ${type} did not arrive within ${timeoutMs}ms. Got: ${this.events.map((e) => e.type).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
allEvents(): SseEvent[] {
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* it lives in one place instead of being re-inlined per spec.
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from './env';
|
||||
|
||||
/** Exchange a JWT for a single-use SSE ticket via POST /api/v1/stream/ticket. */
|
||||
export async function mintSseTicket(jwt: string): Promise<string> {
|
||||
@@ -13,7 +12,8 @@ export async function mintSseTicket(jwt: string): Promise<string> {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
if (res.status !== 200) throw new Error(`mintSseTicket failed: ${res.status} ${await res.text()}`);
|
||||
if (res.status !== 200)
|
||||
throw new Error(`mintSseTicket failed: ${res.status} ${await res.text()}`);
|
||||
return (await res.json()).ticket;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,47 @@ export async function longPress(page: Page, locator: Locator, durationMs = 600)
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* A sub-threshold press dispatched ENTIRELY in-browser: `pointerdown`, an in-browser
|
||||
* `setTimeout(holdMs)`, then `pointerup` — all on the element carrying `use:longpress`.
|
||||
*
|
||||
* Why not `longPress` (page.mouse + waitForTimeout) for the "quick tap must NOT long-press"
|
||||
* case: those are three independent CDP round-trips with a Node-side wait between the down
|
||||
* and the up. Under cross-test load the browser can process the `pointerup` far later than
|
||||
* `holdMs` of *browser* time, so the app's real 500 ms long-press timer fires and the
|
||||
* ContextSheet opens on what the test intended as a quick tap. (Observed: a 200 ms-intended
|
||||
* tap opening the sheet ~1 % of full-suite runs.) Dispatching both events from one
|
||||
* `evaluate` puts the down→up gap and the app's 500 ms timer on the SAME clock: even if the
|
||||
* event loop stalls, the earlier-scheduled `pointerup` (holdMs) still fires before the
|
||||
* later 500 ms timer, so the relationship holds. Mirrors `doubleTap`.
|
||||
*
|
||||
* No trusted `click` follows a synthetic `pointerup`, so use this ONLY where the assertion is
|
||||
* that the long-press did NOT fire — not where a real click must land (see `longPress`).
|
||||
*/
|
||||
export async function quickTap(locator: Locator, holdMs = 200) {
|
||||
await locator.evaluate((el: HTMLElement, ms: number) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
const mk = (type: string) =>
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pointerType: 'touch',
|
||||
isPrimary: true,
|
||||
});
|
||||
el.dispatchEvent(mk('pointerdown'));
|
||||
return new Promise<void>((resolve) =>
|
||||
setTimeout(() => {
|
||||
el.dispatchEvent(mk('pointerup'));
|
||||
resolve();
|
||||
}, ms)
|
||||
);
|
||||
}, holdMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two rapid pointer-event pairs within the doubletap action's 300 ms window.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BASE } from './env';
|
||||
/**
|
||||
* Node-side multipart upload helper. Lets adversarial specs post arbitrary
|
||||
* bytes with arbitrary `Content-Type` claims to /api/v1/upload without
|
||||
@@ -11,8 +12,6 @@
|
||||
*/
|
||||
// Node 22+ ships FormData and Blob as globals — no import needed.
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
|
||||
export type UploadOptions = {
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
@@ -20,7 +19,11 @@ export type UploadOptions = {
|
||||
hashtags?: string;
|
||||
};
|
||||
|
||||
export async function uploadRaw(token: string, body: Uint8Array | Buffer, opts: UploadOptions = {}) {
|
||||
export async function uploadRaw(
|
||||
token: string,
|
||||
body: Uint8Array | Buffer,
|
||||
opts: UploadOptions = {}
|
||||
) {
|
||||
const form = new FormData();
|
||||
const blob = new Blob([body as any], { type: opts.contentType ?? 'application/octet-stream' });
|
||||
form.append('file', blob as any, opts.filename ?? 'upload.bin');
|
||||
@@ -41,8 +44,74 @@ export async function uploadFile(token: string, path: string, opts: UploadOption
|
||||
}
|
||||
|
||||
/** Tiny valid JPEG header — magic bytes only, useful for "claim image but is N MB of zeros" tests. */
|
||||
export const JPEG_MAGIC = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]);
|
||||
export const JPEG_MAGIC = new Uint8Array([
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46,
|
||||
]);
|
||||
/** Tiny valid PNG magic bytes. */
|
||||
export const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
/** ELF header — the magic bytes of a Linux executable. `infer` reports `application/x-executable`. */
|
||||
export const ELF_MAGIC = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]);
|
||||
|
||||
/**
|
||||
* Upload whose request body PAUSES mid-stream until you release it.
|
||||
*
|
||||
* This exists to make the release-vs-upload race deterministic instead of hoped-for. The bug it
|
||||
* guards is a TOCTOU: `upload` checks `uploads_locked_at` BEFORE reading the body (minutes, for a
|
||||
* big video) and commits the row afterwards — so a release landing in between used to let the photo
|
||||
* commit AFTER the export snapshot, leaving it in the live feed but permanently absent from the
|
||||
* keepsake.
|
||||
*
|
||||
* Racing N normal uploads against a release can't prove anything: if they all happen to commit
|
||||
* first (or all get rejected), the assertion passes on broken code too. Here the server is *stuck*
|
||||
* mid-body with its pre-flight check already passed, so the release provably lands inside the
|
||||
* window. Await `checkPassed`, fire the release, then `finish()`.
|
||||
*/
|
||||
export function uploadPausedMidStream(
|
||||
token: string,
|
||||
head: Buffer,
|
||||
tail: Buffer,
|
||||
opts: UploadOptions = {}
|
||||
): { checkPassed: Promise<void>; finish: () => void; response: Promise<Response> } {
|
||||
const boundary = '----eventsnapPausedBoundary' + Math.random().toString(16).slice(2);
|
||||
const filename = opts.filename ?? 'paused.jpg';
|
||||
const contentType = opts.contentType ?? 'image/jpeg';
|
||||
|
||||
const preamble = Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
|
||||
`Content-Type: ${contentType}\r\n\r\n`
|
||||
);
|
||||
const epilogue = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
|
||||
let releaseBody: () => void;
|
||||
const gate = new Promise<void>((r) => (releaseBody = r));
|
||||
let markCheckPassed: () => void;
|
||||
const checkPassed = new Promise<void>((r) => (markCheckPassed = r));
|
||||
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
controller.enqueue(new Uint8Array(preamble));
|
||||
controller.enqueue(new Uint8Array(head));
|
||||
// The server has now read the part headers and the first bytes — which means it is PAST its
|
||||
// pre-flight lock check and is sitting in the body loop. This is the window.
|
||||
markCheckPassed();
|
||||
await gate;
|
||||
controller.enqueue(new Uint8Array(tail));
|
||||
controller.enqueue(new Uint8Array(epilogue));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const response = fetch(`${BASE}/api/v1/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
// Node's fetch requires this for a streaming request body.
|
||||
duplex: 'half',
|
||||
} as RequestInit & { duplex: 'half' });
|
||||
|
||||
return { checkPassed, finish: () => releaseBody(), response };
|
||||
}
|
||||
|
||||
1514
e2e/package-lock.json
generated
1514
e2e/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,13 +14,23 @@
|
||||
"stack:up": "docker compose -f docker-compose.test.yml up -d --build",
|
||||
"stack:down": "docker compose -f docker-compose.test.yml down -v",
|
||||
"stack:logs": "docker compose -f docker-compose.test.yml logs -f",
|
||||
"install:browsers": "playwright install --with-deps chromium firefox webkit"
|
||||
"install:browsers": "playwright install --with-deps chromium firefox webkit",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"pg": "^8.13.1",
|
||||
"@types/pg": "^8.11.10",
|
||||
"typescript": "^5.7.0"
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-prettier": "^9.1.2",
|
||||
"globals": "^15.15.0",
|
||||
"pg": "^8.13.1",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "^5.7.0",
|
||||
"typescript-eslint": "^8.64.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,50 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Page object for `/export` (see [frontend/src/routes/export/+page.svelte]).
|
||||
*
|
||||
* The two archive buttons are BOTH labelled exactly "Download" — the format is carried by
|
||||
* the surrounding card ("ZIP-Archiv" / "HTML-Viewer"), not by the accessible name. Earlier
|
||||
* versions of this file looked for `/zip.*herunter/i` and `/^herunterladen$/i`, which match
|
||||
* nothing on this page; any test asserting `not.toBeVisible()` on those passed no matter
|
||||
* what the page rendered. Locators here must match the real UI, so scope by card.
|
||||
*
|
||||
* "Herunterladen" IS a real label — but only inside the HTML-guide confirm modal, which is
|
||||
* a different element entirely.
|
||||
*/
|
||||
export class ExportPage {
|
||||
readonly page: Page;
|
||||
/** Empty state shown while the host has not released the export yet. */
|
||||
readonly notAvailableBanner: Locator;
|
||||
/** Any "Download" button on the page (both cards). Useful for absence assertions. */
|
||||
readonly downloadButtons: Locator;
|
||||
readonly zipDownloadButton: Locator;
|
||||
readonly htmlDownloadButton: Locator;
|
||||
/** Confirm button inside the "Hinweis zum HTML-Viewer" modal. */
|
||||
readonly htmlGuideModalContinue: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.notAvailableBanner = page.locator('[data-testid="export-not-available"]');
|
||||
this.zipDownloadButton = page.getByRole('button', { name: /zip.*herunter|herunter.*zip/i }).first();
|
||||
this.htmlDownloadButton = page.getByRole('button', { name: /html.*herunter|herunter.*html/i }).first();
|
||||
this.htmlGuideModalContinue = page.getByRole('button', { name: /^herunterladen$/i });
|
||||
this.notAvailableBanner = page.getByText('Export noch nicht verfügbar');
|
||||
this.downloadButtons = page.getByRole('button', { name: 'Download', exact: true });
|
||||
this.zipDownloadButton = this.cardButton('ZIP-Archiv');
|
||||
this.htmlDownloadButton = this.cardButton('HTML-Viewer');
|
||||
this.htmlGuideModalContinue = page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: /^herunterladen$/i });
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Download" button inside the card whose heading is `heading`.
|
||||
*
|
||||
* Scoped to the card element (`div.rounded-xl`) rather than "any div containing the
|
||||
* heading" — the latter also matches the page wrapper, which contains BOTH cards' buttons.
|
||||
*/
|
||||
private cardButton(heading: string): Locator {
|
||||
return this.page
|
||||
.locator('div.rounded-xl')
|
||||
.filter({ has: this.page.getByRole('heading', { name: heading, exact: true }) })
|
||||
.getByRole('button', { name: 'Download', exact: true });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
|
||||
@@ -11,8 +11,12 @@ export class HostDashboard {
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.statsSection = page.locator('[data-testid="host-stats"]');
|
||||
this.lockEventButton = page.getByRole('button', { name: /uploads sperren|event sperren|sperren/i }).first();
|
||||
this.unlockEventButton = page.getByRole('button', { name: /uploads freigeben|entsperren/i }).first();
|
||||
this.lockEventButton = page
|
||||
.getByRole('button', { name: /uploads sperren|event sperren|sperren/i })
|
||||
.first();
|
||||
this.unlockEventButton = page
|
||||
.getByRole('button', { name: /uploads freigeben|entsperren/i })
|
||||
.first();
|
||||
this.releaseGalleryButton = page.getByRole('button', { name: /galerie freigeben/i });
|
||||
this.userSearchInput = page.getByPlaceholder(/suche|search/i).first();
|
||||
}
|
||||
@@ -22,15 +26,23 @@ export class HostDashboard {
|
||||
}
|
||||
|
||||
userRow(displayName: string): Locator {
|
||||
return this.page.locator('tr,li,div', { hasText: displayName }).filter({ has: this.page.getByRole('button') }).first();
|
||||
return this.page
|
||||
.locator('tr,li,div', { hasText: displayName })
|
||||
.filter({ has: this.page.getByRole('button') })
|
||||
.first();
|
||||
}
|
||||
|
||||
async banUser(displayName: string, hideUploads = false) {
|
||||
async banUser(displayName: string) {
|
||||
// A ban always hides — the confirm dialog is a plain confirm, no "hide uploads" checkbox
|
||||
// (removed from the UI; the endpoint takes no body).
|
||||
const row = this.userRow(displayName);
|
||||
await row.getByRole('button', { name: /sperren/i }).first().click();
|
||||
if (hideUploads) {
|
||||
await this.page.getByRole('checkbox', { name: /ausblenden/i }).check();
|
||||
}
|
||||
await this.page.getByRole('button', { name: /bestätigen|sperren/i }).last().click();
|
||||
await row
|
||||
.getByRole('button', { name: /sperren/i })
|
||||
.first()
|
||||
.click();
|
||||
await this.page
|
||||
.getByRole('button', { name: /bestätigen|sperren/i })
|
||||
.last()
|
||||
.click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,29 @@ const CHROMIUM_PERMISSIONS = ['camera', 'microphone', 'clipboard-read', 'clipboa
|
||||
export default defineConfig({
|
||||
testDir: './specs',
|
||||
outputDir: './test-results',
|
||||
fullyParallel: false, // Single shared backend → tests TRUNCATE between, so don't run in parallel.
|
||||
fullyParallel: false, // Single shared backend → tests TRUNCATE between, so don't run in parallel.
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1, // One worker. Multi-worker needs per-worker isolated DBs (Phase 2+).
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { open: 'never', outputFolder: './playwright-report' }],
|
||||
],
|
||||
// NO RETRIES — not even in CI. This is deliberate and it is the opposite of the usual advice.
|
||||
//
|
||||
// The standard case for retries is "the environment is flaky, the product isn't." That argument
|
||||
// does not hold here. This backend's real bugs ARE races (the last several fixes were all export
|
||||
// concurrency), and from the outside a race is indistinguishable from a flake — so a retry
|
||||
// resolves that ambiguity, silently, in favour of "flake", every single time.
|
||||
//
|
||||
// The numbers make it concrete. A test that fails 3% of runs reports a bug roughly 1 run in 33.
|
||||
// Under `retries: 2` it fails the build only when it fails three times in a row: ~1 in 37,000.
|
||||
// We had exactly such a test, and it was reporting a REAL user-facing bug (a suggestion button
|
||||
// that committed on mousedown and destroyed itself mid-click). Retries would have buried it
|
||||
// forever, and buried it GREEN, so nobody would even have seen an amber.
|
||||
//
|
||||
// Worse, a retry does not re-run the failing conditions — it runs a CLEANER environment (the
|
||||
// interfering background worker from the previous test has since finished). So the mechanism is
|
||||
// specifically good at hiding exactly the cross-test contamination this suite is prone to.
|
||||
//
|
||||
// A flake here is a bug report. Treat it as one.
|
||||
retries: 0,
|
||||
workers: 1, // One worker. Multi-worker needs per-worker isolated DBs (Phase 2+).
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: './playwright-report' }]],
|
||||
globalSetup: './global-setup.ts',
|
||||
globalTeardown: './global-teardown.ts',
|
||||
timeout: 60_000,
|
||||
@@ -86,7 +101,7 @@ export default defineConfig({
|
||||
{
|
||||
name: 'chromium-galaxy-s22',
|
||||
use: {
|
||||
...devices['Galaxy S9+'], // Playwright doesn't ship S22 yet — S9+ is the closest Samsung descriptor.
|
||||
...devices['Galaxy S9+'], // Playwright doesn't ship S22 yet — S9+ is the closest Samsung descriptor.
|
||||
viewport: { width: 360, height: 780 },
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
||||
@@ -127,7 +142,12 @@ export default defineConfig({
|
||||
{
|
||||
name: 'webkit-iphone',
|
||||
use: { ...devices['iPhone 14 Pro'] },
|
||||
grep: /@smoke/,
|
||||
// The PRIMARY user of this app is a wedding guest opening a QR link in iOS Safari. Gating
|
||||
// that on `@smoke` — which exists on exactly two specs — meant the entire iOS guarantee was
|
||||
// one happy path and one join test. Every other UA here is a secondary browser and a smoke
|
||||
// check is proportionate; WebKit is not. Give it the core journeys the guest actually walks:
|
||||
// join/recover, upload, and browse the feed.
|
||||
testMatch: ['**/__smoke/**', '**/01-auth/**', '**/02-upload/**', '**/03-feed/**'],
|
||||
},
|
||||
{
|
||||
name: 'firefox-android',
|
||||
@@ -137,8 +157,7 @@ export default defineConfig({
|
||||
// Firefox rejects `isMobile` (Chromium-only). Keep the phone viewport +
|
||||
// Android UA for coverage, but drop the unsupported flag.
|
||||
isMobile: false,
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0',
|
||||
userAgent: 'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0',
|
||||
},
|
||||
grep: /@smoke/,
|
||||
},
|
||||
|
||||
@@ -13,17 +13,24 @@ test.describe('Auth — admin login', () => {
|
||||
await login.login(ADMIN_PASSWORD);
|
||||
await page.waitForURL('**/admin', { timeout: 10_000 });
|
||||
|
||||
// Admin JWT should now be in localStorage (admin uses the same key, just with role=admin in the payload)
|
||||
const role = await page.evaluate(() => {
|
||||
const token = localStorage.getItem('eventsnap_jwt');
|
||||
if (!token) return null;
|
||||
try {
|
||||
return JSON.parse(atob(token.split('.')[1])).role;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// The admin JWT lands in sessionStorage (USER_JOURNEYS §11.1) — NOT localStorage — so the
|
||||
// elevated credential doesn't survive a tab/browser close on a shared "event laptop".
|
||||
const stores = await page.evaluate(() => {
|
||||
const decodeRole = (t: string | null) => {
|
||||
if (!t) return null;
|
||||
try {
|
||||
return JSON.parse(atob(t.split('.')[1])).role;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return {
|
||||
sessionRole: decodeRole(sessionStorage.getItem('eventsnap_jwt')),
|
||||
localToken: localStorage.getItem('eventsnap_jwt'),
|
||||
};
|
||||
});
|
||||
expect(role).toBe('admin');
|
||||
expect(stores.sessionRole).toBe('admin');
|
||||
expect(stores.localToken, 'admin token must not persist in localStorage').toBeNull();
|
||||
});
|
||||
|
||||
test('wrong password → error, no token written', async ({ page }) => {
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
test.describe('Navigation — back chevrons', () => {
|
||||
test('/recover back chevron navigates to /feed (which redirects to /join when unauth)', async ({ page }) => {
|
||||
test('/recover back chevron navigates to /feed (which redirects to /join when unauth)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/recover');
|
||||
const back = page.getByTestId('recover-back');
|
||||
await expect(back).toBeVisible();
|
||||
@@ -15,7 +17,11 @@ test.describe('Navigation — back chevrons', () => {
|
||||
await page.waitForURL(/\/(join|feed)$/);
|
||||
});
|
||||
|
||||
test('/export back chevron returns the authenticated guest to /feed', async ({ page, guest, signIn }) => {
|
||||
test('/export back chevron returns the authenticated guest to /feed', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('ExportBack');
|
||||
await signIn(page, g);
|
||||
await page.goto('/export');
|
||||
|
||||
@@ -41,7 +41,10 @@ test.describe('Auth — join flow', () => {
|
||||
await page.waitForURL('**/feed', { timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('returning guest, new device: same name shows the inline recovery form', async ({ page, guest }) => {
|
||||
test('returning guest, new device: same name shows the inline recovery form', async ({
|
||||
page,
|
||||
guest,
|
||||
}) => {
|
||||
const original = await guest('Charlie');
|
||||
|
||||
// Brand-new browser context (cleared storage) — landing on /join with same name
|
||||
|
||||
@@ -7,7 +7,11 @@ import { AccountPage } from '../../page-objects';
|
||||
import { readStorage } from '../../helpers/storage-helpers';
|
||||
|
||||
test.describe('Auth — leave event', () => {
|
||||
test('leave event clears localStorage and redirects to /join', async ({ page, guest, signIn }) => {
|
||||
test('leave event clears localStorage and redirects to /join', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const h = await guest('Jens');
|
||||
await signIn(page, h);
|
||||
|
||||
|
||||
57
e2e/specs/01-auth/logout-everywhere.spec.ts
Normal file
57
e2e/specs/01-auth/logout-everywhere.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* "Sign out everywhere" — DELETE /api/v1/sessions. A security control (revoke every device after a
|
||||
* lost/stolen phone) that had ZERO coverage. Sessions are validated per-request against the DB
|
||||
* (auth middleware resolves the token hash to a live session row), so revocation is observable: a
|
||||
* revoked token must stop authenticating.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
const ctx = (jwt: string) =>
|
||||
fetch(`${BASE}/api/v1/me/context`, { headers: { Authorization: `Bearer ${jwt}` } });
|
||||
|
||||
test.describe('Auth — sign out everywhere', () => {
|
||||
test("DELETE /sessions revokes ALL of the caller's sessions, not just the current one", async ({
|
||||
api,
|
||||
guest,
|
||||
db,
|
||||
}) => {
|
||||
// Two devices for one guest: the join session, plus a second session from a recover login.
|
||||
const g = await guest('MultiDevice');
|
||||
const second = await api.recover(g.displayName, g.pin);
|
||||
const secondJwt = second.body.jwt;
|
||||
|
||||
// Both tokens work, and there really are two distinct sessions.
|
||||
expect((await ctx(g.jwt)).status).toBe(200);
|
||||
expect((await ctx(secondJwt)).status).toBe(200);
|
||||
expect(await db.countSessionsForUser(g.userId)).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Sign out everywhere, authenticated as device one.
|
||||
const res = await fetch(`${BASE}/api/v1/sessions`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
// BOTH devices are now logged out — the whole point of the control. If it only killed the
|
||||
// caller's own session, device two would still be live and a stolen phone would keep access.
|
||||
expect((await ctx(g.jwt)).status, 'the calling device is signed out').toBe(401);
|
||||
expect((await ctx(secondJwt)).status, 'the OTHER device is signed out too').toBe(401);
|
||||
expect(await db.countSessionsForUser(g.userId)).toBe(0);
|
||||
});
|
||||
|
||||
test("one user signing out everywhere does not touch another user's sessions", async ({
|
||||
guest,
|
||||
}) => {
|
||||
const a = await guest('SignsOut');
|
||||
const b = await guest('StaysIn');
|
||||
|
||||
await fetch(`${BASE}/api/v1/sessions`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${a.jwt}` },
|
||||
});
|
||||
|
||||
expect((await ctx(a.jwt)).status).toBe(401);
|
||||
expect((await ctx(b.jwt)).status, "another user's session must be untouched").toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,10 @@ import { JoinPage, RecoverPage } from '../../page-objects';
|
||||
import { clearAllStorage } from '../../helpers/storage-helpers';
|
||||
|
||||
test.describe('Auth — PIN auto-submit', () => {
|
||||
test('inline recovery: 4th digit auto-submits and navigates to /feed', async ({ page, guest }) => {
|
||||
test('inline recovery: 4th digit auto-submits and navigates to /feed', async ({
|
||||
page,
|
||||
guest,
|
||||
}) => {
|
||||
const original = await guest('AutoInline');
|
||||
await clearAllStorage(page);
|
||||
|
||||
@@ -30,7 +33,10 @@ test.describe('Auth — PIN auto-submit', () => {
|
||||
await page.waitForURL('**/feed', { timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('/recover: 4th digit auto-submits when the name is already filled in', async ({ page, guest }) => {
|
||||
test('/recover: 4th digit auto-submits when the name is already filled in', async ({
|
||||
page,
|
||||
guest,
|
||||
}) => {
|
||||
const original = await guest('AutoRecover');
|
||||
await clearAllStorage(page);
|
||||
|
||||
|
||||
117
e2e/specs/01-auth/pin-reset-lifecycle.spec.ts
Normal file
117
e2e/specs/01-auth/pin-reset-lifecycle.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* USER_JOURNEYS §4 — the "I forgot my PIN" in-app request lifecycle, end to end.
|
||||
*
|
||||
* This whole journey had ZERO functional coverage (only the 403 gate, from the authz sweep). The
|
||||
* invariants below are all security-relevant and none of them were tested:
|
||||
* - the always-204 non-enumeration contract (an unknown name must look identical to a known one)
|
||||
* - the per-IP+name throttle (3 / 15 min)
|
||||
* - admins are EXCLUDED from the request queue (they recover via password, not PIN)
|
||||
* - a host reset REVOKES the target's sessions (the forgotten/compromised device is logged out)
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
const requestReset = (displayName: string) =>
|
||||
fetch(`${BASE}/api/v1/recover/request`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: displayName }),
|
||||
});
|
||||
|
||||
test.describe('PIN reset — in-app request lifecycle', () => {
|
||||
test('a request for a real guest queues exactly one request the host can see', async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
db,
|
||||
}) => {
|
||||
const g = await guest('ForgetfulFrida');
|
||||
|
||||
expect((await requestReset('ForgetfulFrida')).status).toBe(204);
|
||||
|
||||
// The host sees it...
|
||||
const list = await api.listPinResetRequests(host.jwt);
|
||||
expect(list.some((r: any) => r.user_id === g.userId)).toBe(true);
|
||||
// ...and it's deduped: a second request does not stack a duplicate (ON CONFLICT DO NOTHING).
|
||||
expect((await requestReset('ForgetfulFrida')).status).toBe(204);
|
||||
expect(await db.countPinResetRequestsForUser(g.userId)).toBe(1);
|
||||
});
|
||||
|
||||
test('an UNKNOWN name is indistinguishable from a known one (204, no queue, no enumeration)', async ({
|
||||
host,
|
||||
api,
|
||||
guest,
|
||||
}) => {
|
||||
// Same status code and no error body as the real path — the only observable difference must be
|
||||
// in the host's queue, which the requester cannot see.
|
||||
const known = await guest('KnownKarl');
|
||||
expect((await requestReset('KnownKarl')).status).toBe(204);
|
||||
expect((await requestReset('no-such-guest-' + Date.now())).status).toBe(204);
|
||||
|
||||
// The unknown name queued nothing; the known one queued exactly one.
|
||||
const list = await api.listPinResetRequests(host.jwt);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].user_id).toBe(known.userId);
|
||||
});
|
||||
|
||||
test('an ADMIN never gets a reset request queued (admins recover via password)', async ({
|
||||
api,
|
||||
host,
|
||||
db,
|
||||
}) => {
|
||||
// The admin user exists (the host fixture logs an admin in to create it). Its display name is "Admin" by
|
||||
// convention; request a reset for it and confirm the queue stays empty — the INSERT filters
|
||||
// `role <> 'admin'`, so queuing a reset for an admin would be a privilege-relevant leak.
|
||||
const admin = (await api.listUsers(host.jwt)).find((u: any) => u.role === 'admin');
|
||||
expect(admin, 'an admin user must exist').toBeTruthy();
|
||||
|
||||
expect((await requestReset(admin.display_name)).status).toBe(204);
|
||||
|
||||
expect(await db.countPinResetRequestsForUser(admin.id)).toBe(0);
|
||||
expect(await api.listPinResetRequests(host.jwt)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('requests are throttled to 3 per 15 min per IP+name', async ({ api, adminToken, guest }) => {
|
||||
await api.patchConfig(adminToken, { rate_limits_enabled: 'true' });
|
||||
await guest('ThrottleTarget');
|
||||
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 5; i++) statuses.push((await requestReset('ThrottleTarget')).status);
|
||||
|
||||
// First 3 accepted, the rest throttled — proving the limiter is real and keyed.
|
||||
expect(statuses.filter((s) => s === 204)).toHaveLength(3);
|
||||
expect(statuses.filter((s) => s === 429).length).toBeGreaterThan(0);
|
||||
|
||||
// Restore so the next test's fixtures aren't affected (rate limits share the backend).
|
||||
await api.patchConfig(adminToken, { rate_limits_enabled: 'false' });
|
||||
});
|
||||
|
||||
test('a host PIN reset revokes the targets sessions and clears the request', async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
db,
|
||||
}) => {
|
||||
const g = await guest('CompromisedCarl');
|
||||
await requestReset('CompromisedCarl');
|
||||
|
||||
// The guest has a live session (the join minted one) and a queued request.
|
||||
expect(await db.countSessionsForUser(g.userId)).toBeGreaterThan(0);
|
||||
expect(await db.countPinResetRequestsForUser(g.userId)).toBe(1);
|
||||
|
||||
// Host resets the PIN.
|
||||
await api.resetUserPin(host.jwt, g.userId);
|
||||
|
||||
// The old device is logged out: the guest's pre-reset JWT no longer authenticates. This is the
|
||||
// security point of a reset — sessions are token-bound, so without the revoke the compromised
|
||||
// device would stay logged in with full access despite the new PIN.
|
||||
const stale = await fetch(`${BASE}/api/v1/me/context`, {
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
});
|
||||
expect(stale.status, 'the pre-reset session must be revoked').toBe(401);
|
||||
expect(await db.countSessionsForUser(g.userId)).toBe(0);
|
||||
|
||||
// And the pending request was resolved.
|
||||
expect(await db.countPinResetRequestsForUser(g.userId)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -26,12 +26,39 @@ test.describe('Auth — /recover route', () => {
|
||||
await expect(recover.errorMessage).toContainText(/PIN ist falsch|falsch/i);
|
||||
});
|
||||
|
||||
test('non-existent name returns the "not found" error', async ({ page }) => {
|
||||
// An unknown name must be INDISTINGUISHABLE from a wrong PIN — same status, same message.
|
||||
//
|
||||
// This test used to assert the exact opposite: that an unknown name produced a distinct
|
||||
// "nicht gefunden" error. That IS the account-enumeration oracle the F4 audit fix removed
|
||||
// (recover now answers an unknown name with the same 401 "PIN ist falsch."), so reintroducing
|
||||
// the vulnerability would have made this test pass and `recover-enumeration.spec.ts` fail — two
|
||||
// tests asserting contradictory things about the same behaviour, one of them for the attacker.
|
||||
//
|
||||
// It also passed for a reason unrelated to the name: this test takes no `guest` fixture, so the
|
||||
// per-test TRUNCATE leaves no event row at all, and recover 404s with "Event nicht gefunden." —
|
||||
// which the old regex happily matched.
|
||||
test('an unknown name is indistinguishable from a wrong PIN (no enumeration oracle)', async ({
|
||||
page,
|
||||
guest,
|
||||
}) => {
|
||||
const handle = await guest('Known');
|
||||
await clearAllStorage(page);
|
||||
const recover = new RecoverPage(page);
|
||||
|
||||
await recover.goto();
|
||||
await recover.recover('Doesnt-Exist-' + Date.now(), '1234');
|
||||
await expect(recover.errorMessage).toContainText(/nicht gefunden|kein/i);
|
||||
const unknownNameError = (await recover.errorMessage.textContent())?.trim();
|
||||
|
||||
await recover.goto();
|
||||
await recover.recover('Known', handle.pin === '9999' ? '0000' : '9999');
|
||||
const wrongPinError = (await recover.errorMessage.textContent())?.trim();
|
||||
|
||||
expect(unknownNameError).toBeTruthy();
|
||||
expect(
|
||||
unknownNameError,
|
||||
'an unknown name must not be distinguishable from a wrong PIN — that is an account-enumeration oracle'
|
||||
).toBe(wrongPinError);
|
||||
expect(unknownNameError).not.toMatch(/nicht gefunden|kein benutzer/i);
|
||||
});
|
||||
|
||||
test('lockout expires and counter resets (per recover handler)', async ({ api, guest, db }) => {
|
||||
|
||||
@@ -31,14 +31,25 @@ test.describe('Upload — gallery path', () => {
|
||||
const h = await guest('Uploader2');
|
||||
const { uploadRaw } = await import('../../helpers/upload-client');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const r1 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG), { filename: 'a.jpg', contentType: 'image/jpeg' });
|
||||
const r2 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG_2), { filename: 'b.jpg', contentType: 'image/jpeg' });
|
||||
const r1 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG), {
|
||||
filename: 'a.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
const r2 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG_2), {
|
||||
filename: 'b.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
expect(r1.status).toBe(201);
|
||||
expect(r2.status).toBe(201);
|
||||
await expect.poll(() => db.countUploadsForUser(h.userId), { timeout: 10_000 }).toBe(2);
|
||||
});
|
||||
|
||||
test('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({ page, guest, signIn, db }) => {
|
||||
test('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
db,
|
||||
}) => {
|
||||
// Previously fixme'd: the UI queue never fired a POST. Root cause was NOT a
|
||||
// navigation/blob timing quirk but an IndexedDB upgrade bug — the v1→v2
|
||||
// `upgrade` callback opened a *new* transaction, which throws during a
|
||||
@@ -74,13 +85,20 @@ test.describe('Upload — gallery path', () => {
|
||||
const { id } = await res.json();
|
||||
|
||||
// Wait up to 10s for the compression worker's SSE.
|
||||
const evt = await sse.waitForEvent('upload-processed', (e) => {
|
||||
const data = typeof e.data === 'string' ? safeJson(e.data) : e.data;
|
||||
return data?.upload_id === id || data?.id === id;
|
||||
}, 10_000).catch(() => null);
|
||||
const evt = await sse
|
||||
.waitForEvent(
|
||||
'upload-processed',
|
||||
(e) => {
|
||||
const data = typeof e.data === 'string' ? safeJson(e.data) : e.data;
|
||||
return data?.upload_id === id || data?.id === id;
|
||||
},
|
||||
10_000
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
// If the SSE didn't arrive in 10s (slow CI, debug mode), at least we know the upload was accepted.
|
||||
if (!evt) console.warn('[finding] upload-processed SSE did not arrive within 10s for upload', id);
|
||||
if (!evt)
|
||||
console.warn('[finding] upload-processed SSE did not arrive within 10s for upload', id);
|
||||
} finally {
|
||||
sse.stop();
|
||||
}
|
||||
@@ -88,5 +106,9 @@ test.describe('Upload — gallery path', () => {
|
||||
});
|
||||
|
||||
function safeJson(s: string) {
|
||||
try { return JSON.parse(s); } catch { return s; }
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
188
e2e/specs/02-upload/quota.spec.ts
Normal file
188
e2e/specs/02-upload/quota.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Storage-quota ENFORCEMENT — the branch that runs in production and, until now, in zero tests.
|
||||
*
|
||||
* `quota_enabled` and `storage_quota_enabled` both default to TRUE in production
|
||||
* (upload.rs: `config::get_bool(.., "quota_enabled", true)`), but global-setup turns them off and
|
||||
* the per-test TRUNCATE re-asserts them as 'false' before EVERY test. So the entire quota path was
|
||||
* dead under test: the pre-check, the `QuotaExceeded` rejection, and — most importantly — the
|
||||
* atomic increment
|
||||
*
|
||||
* UPDATE "user" SET total_upload_bytes = total_upload_bytes + $2
|
||||
* WHERE id = $1 AND total_upload_bytes + $2 <= $3
|
||||
*
|
||||
* whose enforcement is `rows_affected() == 0`. That UPDATE is the ONLY thing stopping two
|
||||
* concurrent uploads from the same guest (phone + laptop, or a double-tap) from both passing the
|
||||
* stale pre-check and both committing. The five existing unit tests cover `quota_limit_bytes()` —
|
||||
* the arithmetic that computes the number — which gave the appearance of coverage while the
|
||||
* enforcement had none.
|
||||
*
|
||||
* Blast radius if it regresses: one guest silently fills the disk, and then NOBODY at the wedding
|
||||
* can upload. Those photos don't exist anywhere else.
|
||||
*
|
||||
* These tests steer the limit via `quota_tolerance` (limit = floor(free_disk * tolerance / active))
|
||||
* rather than hoping a real disk happens to be nearly full.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { join } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { uploadPausedMidStream } from '../../helpers/upload-client';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
const SAMPLE_BYTES = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
|
||||
const SIZE = SAMPLE_BYTES.byteLength;
|
||||
|
||||
interface Quota {
|
||||
enabled: boolean;
|
||||
used_bytes: number;
|
||||
limit_bytes: number | null;
|
||||
active_uploaders: number;
|
||||
free_disk_bytes: number | null;
|
||||
}
|
||||
|
||||
const quotaOf = async (jwt: string): Promise<Quota> =>
|
||||
(await fetch(BASE + '/api/v1/me/quota', { headers: { Authorization: `Bearer ${jwt}` } })).json();
|
||||
|
||||
function upload(jwt: string, name: string) {
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([SAMPLE_BYTES], { type: 'image/jpeg' }), name);
|
||||
form.append('content_type', 'image/jpeg');
|
||||
return fetch(BASE + '/api/v1/upload', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a `quota_tolerance` that makes the per-user ceiling land on `targetBytes`.
|
||||
* limit = floor(free_disk * tolerance / max(active, 1)) ⇒ tolerance = target * active / free.
|
||||
*/
|
||||
async function setLimitTo(
|
||||
api: any,
|
||||
adminToken: string,
|
||||
jwt: string,
|
||||
targetBytes: number
|
||||
): Promise<number> {
|
||||
const q = await quotaOf(jwt);
|
||||
expect(
|
||||
q.free_disk_bytes,
|
||||
'the disk must be readable, else quota fails OPEN and proves nothing'
|
||||
).toBeTruthy();
|
||||
const active = Math.max(q.active_uploaders, 1);
|
||||
const tolerance = (targetBytes * active) / (q.free_disk_bytes as number);
|
||||
await api.patchConfig(adminToken, { quota_tolerance: tolerance.toExponential(12) });
|
||||
|
||||
const after = await quotaOf(jwt);
|
||||
expect(after.enabled).toBe(true);
|
||||
return after.limit_bytes as number;
|
||||
}
|
||||
|
||||
test.describe('Upload — storage quota enforcement', () => {
|
||||
test.beforeEach(async ({ api, adminToken }) => {
|
||||
await api.patchConfig(adminToken, {
|
||||
quota_enabled: 'true',
|
||||
storage_quota_enabled: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
test('an upload that would exceed the quota is rejected with 413 and stores nothing', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('QuotaOver');
|
||||
// Ceiling below one file: the very first upload must be refused.
|
||||
const limit = await setLimitTo(api, adminToken, g.jwt, Math.floor(SIZE / 2));
|
||||
expect(limit).toBeLessThan(SIZE);
|
||||
|
||||
const res = await upload(g.jwt, 'too-big.jpg');
|
||||
expect(res.status, 'over-quota upload must be refused').toBe(413);
|
||||
expect((await res.json()).error).toBe('quota_exceeded');
|
||||
|
||||
// Nothing was accounted, and nothing reached the feed.
|
||||
expect((await quotaOf(g.jwt)).used_bytes).toBe(0);
|
||||
const feed = await (
|
||||
await fetch(BASE + '/api/v1/feed', { headers: { Authorization: `Bearer ${g.jwt}` } })
|
||||
).json();
|
||||
expect(feed.uploads).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('an upload within the quota still succeeds (the guard is not simply "always no")', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('QuotaUnder');
|
||||
await setLimitTo(api, adminToken, g.jwt, SIZE * 4);
|
||||
|
||||
expect((await upload(g.jwt, 'fine.jpg')).status).toBe(201);
|
||||
expect((await quotaOf(g.jwt)).used_bytes).toBe(SIZE);
|
||||
});
|
||||
|
||||
test('two CONCURRENT uploads cannot BOTH slip past the quota (atomic increment)', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('QuotaRacer');
|
||||
|
||||
// Room for exactly ONE file.
|
||||
const limit = await setLimitTo(api, adminToken, g.jwt, Math.floor(SIZE * 1.5));
|
||||
expect(limit).toBeGreaterThanOrEqual(SIZE);
|
||||
expect(limit).toBeLessThan(SIZE * 2);
|
||||
|
||||
// Both uploads are held OPEN MID-BODY, and this is the entire point of the test.
|
||||
//
|
||||
// The handler loads the user row (and therefore snapshots `total_upload_bytes`) at its very
|
||||
// FIRST line — before it streams a single byte of the body. So holding both requests inside
|
||||
// the body loop guarantees both are carrying the same stale `total = 0`, and both pre-checks
|
||||
// will conclude "this fits". Only the conditional UPDATE can then stop the second.
|
||||
//
|
||||
// Firing them with a plain `Promise.all` does NOT reproduce this: the first request completes
|
||||
// and commits before the second even reads the user row, so the *pre-check* rejects the second
|
||||
// and the test passes with the atomic guard deleted. (It did. That is how this test was caught
|
||||
// being vacuous, and why it is written this way.)
|
||||
const head = SAMPLE_BYTES.subarray(0, 32);
|
||||
const tail = SAMPLE_BYTES.subarray(32);
|
||||
const a = uploadPausedMidStream(g.jwt, head, tail, { filename: 'race-a.jpg' });
|
||||
const b = uploadPausedMidStream(g.jwt, head, tail, { filename: 'race-b.jpg' });
|
||||
|
||||
// Both are now past the pre-flight checks and sitting in the body loop, each holding total = 0.
|
||||
await Promise.all([a.checkPassed, b.checkPassed]);
|
||||
|
||||
a.finish();
|
||||
b.finish();
|
||||
const [ra, rb] = await Promise.all([a.response, b.response]);
|
||||
const statuses = [ra.status, rb.status].sort();
|
||||
|
||||
expect(
|
||||
statuses,
|
||||
`exactly one upload may win the race; got ${JSON.stringify(statuses)} — if BOTH are 201 the atomic guard is gone and a single guest can overrun the disk, after which NOBODY at the party can upload`
|
||||
).toEqual([201, 413]);
|
||||
|
||||
// The invariant that actually matters: accounting never exceeds the ceiling.
|
||||
const q = await quotaOf(g.jwt);
|
||||
expect(q.used_bytes).toBe(SIZE);
|
||||
expect(q.used_bytes).toBeLessThanOrEqual(limit);
|
||||
});
|
||||
|
||||
test('GET /me/quota reports the live usage the UI shows the guest', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
}) => {
|
||||
// Zero test hits before this — and it is the source of the "X von Y MB genutzt" widget.
|
||||
const g = await guest('QuotaWidget');
|
||||
await setLimitTo(api, adminToken, g.jwt, SIZE * 10);
|
||||
|
||||
const before = await quotaOf(g.jwt);
|
||||
expect(before.enabled).toBe(true);
|
||||
expect(before.used_bytes).toBe(0);
|
||||
|
||||
expect((await upload(g.jwt, 'one.jpg')).status).toBe(201);
|
||||
|
||||
const after = await quotaOf(g.jwt);
|
||||
expect(after.used_bytes).toBe(SIZE);
|
||||
expect(after.limit_bytes).toBeGreaterThan(after.used_bytes);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,11 @@ const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
||||
const SAMPLE_BYTES = readFileSync(SAMPLE_JPG);
|
||||
|
||||
test.describe('Upload — rate limit', () => {
|
||||
test('4th upload in one hour returns 429 with Retry-After', async ({ api, adminToken, guest }) => {
|
||||
test('4th upload in one hour returns 429 with Retry-After', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
}) => {
|
||||
// Enable inside the test body, AFTER all auto-fixtures (truncate) have run,
|
||||
// so the config can't be reset out from under us by the ordering of hooks
|
||||
// vs fixtures.
|
||||
@@ -53,7 +57,11 @@ test.describe('Upload — rate limit', () => {
|
||||
expect(limited.headers.get('retry-after')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('flipping upload_rate_enabled off bypasses the limit', async ({ api, adminToken, guest }) => {
|
||||
test('flipping upload_rate_enabled off bypasses the limit', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
}) => {
|
||||
await api.patchConfig(adminToken, { upload_rate_enabled: 'false' });
|
||||
|
||||
const h = await guest('NoQuota');
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Comments — UI round-trip (CR1)', () => {
|
||||
test('a comment typed in the lightbox persists to the backend', async ({
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { SseListener } from '../../helpers/sse-listener';
|
||||
import { seedUpload, seedComment, findFeedRow } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
async function like(jwt: string, uploadId: string): Promise<number> {
|
||||
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, {
|
||||
@@ -20,7 +19,10 @@ async function like(jwt: string, uploadId: string): Promise<number> {
|
||||
}
|
||||
|
||||
test.describe('Feed — like + comment', () => {
|
||||
test('a like counts once per user and toggles off on repeat (no double-count)', async ({ api, guest }) => {
|
||||
test('a like counts once per user and toggles off on repeat (no double-count)', async ({
|
||||
api,
|
||||
guest,
|
||||
}) => {
|
||||
const author = await guest('Author');
|
||||
const liker = await guest('Liker');
|
||||
const uploadId = await seedUpload(author.jwt);
|
||||
|
||||
@@ -11,7 +11,11 @@ import { test, expect } from '../../fixtures/test';
|
||||
import { trackStreamOpens } from '../../helpers/sse';
|
||||
|
||||
test.describe('Feed — SSE behavior', () => {
|
||||
test('backgrounding then foregrounding the tab opens a fresh SSE connection', async ({ page, guest, signIn }) => {
|
||||
test('backgrounding then foregrounding the tab opens a fresh SSE connection', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('SseReconnect');
|
||||
const streamOpens = trackStreamOpens(page);
|
||||
|
||||
@@ -25,7 +29,10 @@ test.describe('Feed — SSE behavior', () => {
|
||||
// new stream opens while hidden.
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => 'hidden',
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
// Snapshot the count AFTER backgrounding — this baselines out the initial open (and
|
||||
@@ -36,7 +43,10 @@ test.describe('Feed — SSE behavior', () => {
|
||||
// Foreground again → connectSse() mints a new ticket and opens a new EventSource.
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => 'visible',
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ test.describe('Feed — error toast on user action failures', () => {
|
||||
route.fulfill({
|
||||
status: 429,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'rate_limited', message: 'Zu viele Anfragen — bitte kurz warten.' }),
|
||||
body: JSON.stringify({
|
||||
error: 'rate_limited',
|
||||
message: 'Zu viele Anfragen — bitte kurz warten.',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
* for a guest who's already viewing the feed.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Host — event lock', () => {
|
||||
test('closing the event via API sets uploads_locked_at; opening clears it', async ({ host, api }) => {
|
||||
test('closing the event via API sets uploads_locked_at; opening clears it', async ({
|
||||
host,
|
||||
api,
|
||||
}) => {
|
||||
// The frontend doesn't (yet) render a per-guest "uploads locked" banner on
|
||||
// the feed — that's the journey §9 banner, currently a UX gap. We assert
|
||||
// the API + DB contract here and leave the banner check for once it ships.
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
|
||||
await api.closeEvent(host.jwt);
|
||||
const evRes = await fetch(`${BASE}/api/v1/host/event`, {
|
||||
@@ -38,8 +41,11 @@ test.describe('Host — event lock', () => {
|
||||
// event (USER_JOURNEYS §9.3, FEATURES capability matrix). Only new uploads are
|
||||
// rejected. (An earlier revision froze social interaction too; that contradicted
|
||||
// the documented behavior and was reverted.)
|
||||
test('a closed event still allows likes and comments, but blocks new uploads', async ({ api, host, guest }) => {
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
test('a closed event still allows likes and comments, but blocks new uploads', async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('SocialLocked');
|
||||
|
||||
// Upload while still open so there's a target to interact with.
|
||||
|
||||
@@ -7,20 +7,15 @@ import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload, seedComment } from '../../helpers/seed';
|
||||
|
||||
test.describe('Host — moderation API', () => {
|
||||
test('ban with hide_uploads=true sets the right flags', async ({ api, host, guest }) => {
|
||||
const target = await guest('Banned1');
|
||||
await api.banUser(host.jwt, target.userId, true);
|
||||
const users = await api.listUsers(host.jwt);
|
||||
const row = users.find((u: any) => u.id === target.userId);
|
||||
expect(row?.is_banned).toBe(true);
|
||||
expect(row?.uploads_hidden).toBe(true);
|
||||
});
|
||||
|
||||
test('ban always hides — the hide_uploads flag is ignored (USER_JOURNEYS §10.5)', async ({ api, host, guest }) => {
|
||||
// Ban is now unconditionally a hide: even asking NOT to hide still hides, because a
|
||||
// banned user's content is "gone" everywhere. The legacy hide_uploads arg is ignored.
|
||||
test('ban always hides — a banned user is is_banned AND uploads_hidden (USER_JOURNEYS §9.5)', async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
}) => {
|
||||
// Ban is unconditionally a hide: a banned user's content is "gone" everywhere. The endpoint
|
||||
// takes no body and there is no per-request opt-out (the legacy hide_uploads flag is gone).
|
||||
const target = await guest('Banned2');
|
||||
await api.banUser(host.jwt, target.userId, false);
|
||||
await api.banUser(host.jwt, target.userId);
|
||||
const users = await api.listUsers(host.jwt);
|
||||
const row = users.find((u: any) => u.id === target.userId);
|
||||
expect(row?.is_banned).toBe(true);
|
||||
@@ -29,14 +24,17 @@ test.describe('Host — moderation API', () => {
|
||||
|
||||
test('banned user cannot call /upload', async ({ api, host, guest }) => {
|
||||
const target = await guest('Banned3');
|
||||
await api.banUser(host.jwt, target.userId, false);
|
||||
await api.banUser(host.jwt, target.userId);
|
||||
|
||||
// Direct fetch — multipart body shape is just a marker; the auth middleware should reject before parsing.
|
||||
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${target.jwt}` },
|
||||
body: new FormData(),
|
||||
});
|
||||
const res = await fetch(
|
||||
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${target.jwt}` },
|
||||
body: new FormData(),
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
@@ -88,23 +86,28 @@ test.describe('Host — live role/ban revocation (H1)', () => {
|
||||
// Sanity: host token works.
|
||||
await api.listUsers(host.jwt);
|
||||
|
||||
await api.banUser(adminToken, host.userId, false);
|
||||
await api.banUser(adminToken, host.userId);
|
||||
|
||||
// Banned users are rejected with 403 by the auth extractor before any handler runs.
|
||||
await expect(api.listUsers(host.jwt)).rejects.toThrow(/→ 403/);
|
||||
});
|
||||
|
||||
test('a banned host cannot unban themselves', async ({ api, adminToken, host }) => {
|
||||
await api.banUser(adminToken, host.userId, false);
|
||||
await expect(
|
||||
api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] })
|
||||
).rejects.toThrow(/→ 403/);
|
||||
await api.banUser(adminToken, host.userId);
|
||||
await expect(api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] })).rejects.toThrow(
|
||||
/→ 403/
|
||||
);
|
||||
});
|
||||
|
||||
// H1 / USER_JOURNEYS §10: a banned user keeps *read* access but every write is
|
||||
// rejected with 403. The ban is enforced on write handlers + Require{Host,Admin},
|
||||
// NOT in the base extractor (which would wrongly block reads too).
|
||||
test('a banned user keeps read access but is blocked from writes', async ({ api, host, guest, db }) => {
|
||||
test('a banned user keeps read access but is blocked from writes', async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
db,
|
||||
}) => {
|
||||
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
const target = await guest('BannedRW');
|
||||
const auth = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
|
||||
@@ -114,7 +117,7 @@ test.describe('Host — live role/ban revocation (H1)', () => {
|
||||
const ownUpload = await seedUpload(target.jwt, { caption: 'mine' });
|
||||
const ownComment = await seedComment(target.jwt, ownUpload, 'my comment');
|
||||
|
||||
await api.banUser(host.jwt, target.userId, false);
|
||||
await api.banUser(host.jwt, target.userId);
|
||||
|
||||
// Reads still succeed.
|
||||
const read = await fetch(base + '/api/v1/me/context', { headers: auth(target.jwt) });
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
import { SseListener } from '../../helpers/sse-listener';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Host — live SSE eviction (H3)', () => {
|
||||
test('self-deleting an upload broadcasts upload-deleted', async ({ guest }) => {
|
||||
@@ -24,29 +23,19 @@ test.describe('Host — live SSE eviction (H3)', () => {
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
await sse.waitForEvent(
|
||||
'upload-deleted',
|
||||
(e) => e.data.upload_id === uploadId
|
||||
);
|
||||
await sse.waitForEvent('upload-deleted', (e) => e.data.upload_id === uploadId);
|
||||
});
|
||||
|
||||
test('banning a user with hide_uploads broadcasts user-hidden', async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
}) => {
|
||||
test('banning a user broadcasts user-hidden', async ({ api, host, guest }) => {
|
||||
const target = await guest('HideTarget');
|
||||
await seedUpload(target.jwt, { caption: 'should vanish' });
|
||||
|
||||
const sse = new SseListener();
|
||||
await sse.start(host.jwt);
|
||||
|
||||
await api.banUser(host.jwt, target.userId, true);
|
||||
await api.banUser(host.jwt, target.userId);
|
||||
|
||||
await sse.waitForEvent(
|
||||
'user-hidden',
|
||||
(e) => e.data.user_id === target.userId
|
||||
);
|
||||
await sse.waitForEvent('user-hidden', (e) => e.data.user_id === target.userId);
|
||||
});
|
||||
|
||||
// Frontend regression: the broadcasts above are inert if the client never
|
||||
@@ -69,7 +58,7 @@ test.describe('Host — live SSE eviction (H3)', () => {
|
||||
await expect(page.getByText('evict-me-live-xyz').first()).toBeVisible();
|
||||
|
||||
// Host hides the target — the viewer's feed must drop the card via SSE, no reload.
|
||||
await api.banUser(host.jwt, target.userId, true);
|
||||
await api.banUser(host.jwt, target.userId);
|
||||
await expect(page.getByText('evict-me-live-xyz')).toHaveCount(0, { timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* guest can't hit host/admin; host can't hit admin.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Authorization escalation guards', () => {
|
||||
test('guest → POST /host/event/close returns 403', async ({ guest }) => {
|
||||
|
||||
175
e2e/specs/05-admin/authz-sweep.spec.ts
Normal file
175
e2e/specs/05-admin/authz-sweep.spec.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Table-driven privilege sweep over EVERY host- and admin-gated route.
|
||||
*
|
||||
* Before this file, exactly THREE authorization assertions existed in the whole suite (guest →
|
||||
* /host/event/close, guest → GET /admin/config, host → GET /admin/config). Sixteen of nineteen
|
||||
* privileged routes had no test proving a guest is turned away — including:
|
||||
*
|
||||
* POST /host/users/{id}/pin-reset → reset any guest's PIN, then log in as them via /recover.
|
||||
* That is account takeover, and nothing guarded it.
|
||||
* DELETE /host/upload/{id} → delete any guest's photo (unrecoverable after the party).
|
||||
* POST /host/event/open → retire the released keepsake and reopen uploads to everyone.
|
||||
* PATCH /admin/config → only GET was 403-tested; a guest WRITING config could
|
||||
* disable quotas and rate limits outright.
|
||||
*
|
||||
* The point of a table is that adding a route to the router and forgetting to protect it should
|
||||
* make a test fail. So this asserts the WHOLE privileged surface, not a sample of it.
|
||||
*
|
||||
* Deliberately NOT covered here: the read-only-ban model (a banned user keeps read access and can
|
||||
* still download the keepsake) is BY DESIGN, and is asserted in 07-adversarial/authorization-deep.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
type Method = 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
interface Route {
|
||||
method: Method;
|
||||
path: (victimId: string) => string;
|
||||
name: string;
|
||||
body?: unknown;
|
||||
/** admin-only routes must also reject a HOST, not just a guest. */
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
const ROUTES: Route[] = [
|
||||
// ── Host surface ────────────────────────────────────────────────────────
|
||||
{ method: 'GET', path: () => '/api/v1/host/event', name: 'GET /host/event' },
|
||||
{ method: 'POST', path: () => '/api/v1/host/event/close', name: 'POST /host/event/close' },
|
||||
{ method: 'POST', path: () => '/api/v1/host/event/open', name: 'POST /host/event/open' },
|
||||
{
|
||||
method: 'POST',
|
||||
path: () => '/api/v1/host/gallery/release',
|
||||
name: 'POST /host/gallery/release',
|
||||
},
|
||||
{ method: 'POST', path: () => '/api/v1/host/export/rebuild', name: 'POST /host/export/rebuild' },
|
||||
{ method: 'GET', path: () => '/api/v1/host/users', name: 'GET /host/users' },
|
||||
{
|
||||
method: 'POST',
|
||||
path: (v) => `/api/v1/host/users/${v}/ban`,
|
||||
name: 'POST /host/users/{id}/ban',
|
||||
body: {},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: (v) => `/api/v1/host/users/${v}/unban`,
|
||||
name: 'POST /host/users/{id}/unban',
|
||||
body: {},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
path: (v) => `/api/v1/host/users/${v}/role`,
|
||||
name: 'PATCH /host/users/{id}/role',
|
||||
body: { role: 'host' },
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: (v) => `/api/v1/host/users/${v}/pin-reset`,
|
||||
name: 'POST /host/users/{id}/pin-reset',
|
||||
body: {},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: () => '/api/v1/host/pin-reset-requests',
|
||||
name: 'GET /host/pin-reset-requests',
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: (v) => `/api/v1/host/pin-reset-requests/${v}`,
|
||||
name: 'DELETE /host/pin-reset-requests/{id}',
|
||||
},
|
||||
{ method: 'DELETE', path: (v) => `/api/v1/host/upload/${v}`, name: 'DELETE /host/upload/{id}' },
|
||||
{ method: 'DELETE', path: (v) => `/api/v1/host/comment/${v}`, name: 'DELETE /host/comment/{id}' },
|
||||
|
||||
// ── Admin surface ───────────────────────────────────────────────────────
|
||||
{ method: 'GET', path: () => '/api/v1/admin/stats', name: 'GET /admin/stats', adminOnly: true },
|
||||
{ method: 'GET', path: () => '/api/v1/admin/config', name: 'GET /admin/config', adminOnly: true },
|
||||
{
|
||||
method: 'PATCH',
|
||||
path: () => '/api/v1/admin/config',
|
||||
name: 'PATCH /admin/config',
|
||||
adminOnly: true,
|
||||
body: { quota_enabled: 'false' },
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: () => '/api/v1/admin/export/jobs',
|
||||
name: 'GET /admin/export/jobs',
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
async function call(route: Route, jwt: string, victimId: string): Promise<number> {
|
||||
const res = await fetch(BASE + route.path(victimId), {
|
||||
method: route.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
...(route.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
...(route.body !== undefined ? { body: JSON.stringify(route.body) } : {}),
|
||||
});
|
||||
return res.status;
|
||||
}
|
||||
|
||||
test.describe('Authorization sweep — every privileged route', () => {
|
||||
for (const route of ROUTES) {
|
||||
test(`a GUEST is refused: ${route.name}`, async ({ guest }) => {
|
||||
const attacker = await guest('AuthzAttacker');
|
||||
const victim = await guest('AuthzVictim');
|
||||
|
||||
const status = await call(route, attacker.jwt, victim.userId);
|
||||
|
||||
// 403 exactly. NOT "some 4xx" — a 404 would mean the request got past the role gate and
|
||||
// merely failed to find the resource, which is precisely the vacuity this suite has been
|
||||
// bitten by: it would still pass with the RequireHost/RequireAdmin extractor deleted.
|
||||
expect(status, `${route.name} must reject a guest with 403, got ${status}`).toBe(403);
|
||||
});
|
||||
}
|
||||
|
||||
for (const route of ROUTES.filter((r) => r.adminOnly)) {
|
||||
test(`a HOST is refused: ${route.name}`, async ({ host, guest }) => {
|
||||
const victim = await guest('AuthzVictim');
|
||||
|
||||
const status = await call(route, host.jwt, victim.userId);
|
||||
|
||||
expect(
|
||||
status,
|
||||
`${route.name} is admin-only and must reject a host with 403, got ${status}`
|
||||
).toBe(403);
|
||||
});
|
||||
}
|
||||
|
||||
// The truncate endpoint wipes every table AND the media directory. It is test-mode only, but it
|
||||
// is reachable over HTTP and gated solely by RequireAdmin — so it deserves the same proof.
|
||||
test('a GUEST is refused: POST /admin/__truncate (wipes the whole event)', async ({ guest }) => {
|
||||
const attacker = await guest('TruncateAttacker');
|
||||
const res = await fetch(`${BASE}/api/v1/admin/__truncate`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${attacker.jwt}` },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('a HOST is refused: POST /admin/__truncate', async ({ host }) => {
|
||||
const res = await fetch(`${BASE}/api/v1/admin/__truncate`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${host.jwt}` },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
// An unauthenticated caller must not get further than an authenticated-but-unprivileged one.
|
||||
test('an ANONYMOUS caller is refused every privileged route', async () => {
|
||||
const victim = '00000000-0000-0000-0000-000000000000';
|
||||
for (const route of ROUTES) {
|
||||
const res = await fetch(BASE + route.path(victim), {
|
||||
method: route.method,
|
||||
headers: route.body !== undefined ? { 'Content-Type': 'application/json' } : {},
|
||||
...(route.body !== undefined ? { body: JSON.stringify(route.body) } : {}),
|
||||
});
|
||||
expect(
|
||||
[401, 403],
|
||||
`${route.name} must reject an anonymous caller, got ${res.status}`
|
||||
).toContain(res.status);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,21 +13,27 @@ test.describe('Admin — config API', () => {
|
||||
await api.patchConfig(adminToken, { max_image_size_mb: '20' });
|
||||
});
|
||||
|
||||
test('non-numeric value for a numeric key is rejected', async ({ api, adminToken }) => {
|
||||
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ max_image_size_mb: 'not-a-number' }),
|
||||
});
|
||||
test('non-numeric value for a numeric key is rejected', async ({ adminToken }) => {
|
||||
const res = await fetch(
|
||||
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ max_image_size_mb: 'not-a-number' }),
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('unknown config key is rejected (whitelist enforced)', async ({ adminToken }) => {
|
||||
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ totally_fake_key: '1' }),
|
||||
});
|
||||
const res = await fetch(
|
||||
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ totally_fake_key: '1' }),
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
@@ -40,16 +46,23 @@ test.describe('Admin — config API', () => {
|
||||
cfg = await api.getConfig(adminToken);
|
||||
expect(cfg.upload_rate_enabled).toBe('false');
|
||||
|
||||
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ upload_rate_enabled: 'maybe' }),
|
||||
});
|
||||
const res = await fetch(
|
||||
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ upload_rate_enabled: 'maybe' }),
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('privacy_note round-trips verbatim, preserving whitespace + newlines', async ({ api, adminToken }) => {
|
||||
const note = ' Datenschutz\n • Wir verwenden keine Cookies.\n • Alles bleibt im Browser.\n\n— Dein Host';
|
||||
test('privacy_note round-trips verbatim, preserving whitespace + newlines', async ({
|
||||
api,
|
||||
adminToken,
|
||||
}) => {
|
||||
const note =
|
||||
' Datenschutz\n • Wir verwenden keine Cookies.\n • Alles bleibt im Browser.\n\n— Dein Host';
|
||||
await api.patchConfig(adminToken, { privacy_note: note });
|
||||
const cfg = await api.getConfig(adminToken);
|
||||
expect(cfg.privacy_note).toBe(note);
|
||||
@@ -58,7 +71,11 @@ test.describe('Admin — config API', () => {
|
||||
});
|
||||
|
||||
test.describe('Admin — stats', () => {
|
||||
test('GET /admin/stats returns matching counts after seeding users', async ({ api, adminToken, guest }) => {
|
||||
test('GET /admin/stats returns matching counts after seeding users', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
}) => {
|
||||
await guest('Stat1');
|
||||
await guest('Stat2');
|
||||
await guest('Stat3');
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Export — no public leak (CR2)', () => {
|
||||
test('a real export is downloadable only via the gated endpoint, never via /media', async ({
|
||||
@@ -26,7 +25,10 @@ test.describe('Export — no public leak (CR2)', () => {
|
||||
await seedUpload(host.jwt, { caption: 'in the export' });
|
||||
|
||||
// Host releases the gallery → spawns the real zip/html export jobs.
|
||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer });
|
||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||||
method: 'POST',
|
||||
headers: bearer,
|
||||
});
|
||||
expect(rel.status).toBe(204);
|
||||
|
||||
// Wait for the real zip job to finish writing the archive to disk.
|
||||
@@ -49,7 +51,10 @@ test.describe('Export — no public leak (CR2)', () => {
|
||||
|
||||
// …but IS retrievable via the gated single-use ticket endpoint. This proves the
|
||||
// 404 above means "not public", not merely "no file was produced".
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { method: 'POST', headers: bearer });
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||
method: 'POST',
|
||||
headers: bearer,
|
||||
});
|
||||
const { ticket } = await ticketRes.json();
|
||||
const dl = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
|
||||
expect(dl.status).toBe(200);
|
||||
|
||||
@@ -14,8 +14,7 @@ import { test, expect } from '../../fixtures/test';
|
||||
import { uploadRaw } from '../../helpers/upload-client';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Export — video streaming (P4)', () => {
|
||||
test('a real video upload is streamed into Memories.zip (not dropped)', async ({ host }) => {
|
||||
@@ -29,7 +28,10 @@ test.describe('Export — video streaming (P4)', () => {
|
||||
const { id } = await up.json();
|
||||
|
||||
// Release the gallery → spawns the real zip + html export jobs.
|
||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer });
|
||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||||
method: 'POST',
|
||||
headers: bearer,
|
||||
});
|
||||
expect(rel.status).toBe(204);
|
||||
|
||||
// Wait for the HTML (Memories.zip) job — that's the one whose media staging P4 changed.
|
||||
@@ -44,7 +46,10 @@ test.describe('Export — video streaming (P4)', () => {
|
||||
.toBe('done');
|
||||
|
||||
// Download Memories.zip via the gated single-use ticket.
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { method: 'POST', headers: bearer });
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||
method: 'POST',
|
||||
headers: bearer,
|
||||
});
|
||||
const { ticket } = await ticketRes.json();
|
||||
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
|
||||
expect(dl.status).toBe(200);
|
||||
|
||||
@@ -11,21 +11,60 @@ import { ExportPage } from '../../page-objects';
|
||||
const SLUG = 'e2e-test-event';
|
||||
|
||||
test.describe('Export — release and download', () => {
|
||||
test('/export shows the "not yet available" state before release', async ({ page, guest, signIn }) => {
|
||||
test('/export shows the "not yet available" state before release', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('PreRelease');
|
||||
await signIn(page, g);
|
||||
const exportPage = new ExportPage(page);
|
||||
await exportPage.goto();
|
||||
// The page shouldn't show download buttons before release.
|
||||
await expect(page.getByRole('button', { name: /^herunterladen$/i })).not.toBeVisible();
|
||||
|
||||
// POSITIVE anchor first. An absence-only assertion ("no download button") is green on a
|
||||
// blank page, a 404, or an unhydrated shell — i.e. it would pass even with the buttons
|
||||
// rendered, if the locator were wrong. Pin the empty state we actually expect.
|
||||
await expect(exportPage.notAvailableBanner).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// And only THEN the absence: no download affordance exists before release. This uses the
|
||||
// real button label ("Download"), so rendering a download button here turns it red.
|
||||
await expect(exportPage.downloadButtons).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('/export shows enabled download buttons once released and the jobs are done', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
db,
|
||||
}) => {
|
||||
const g = await guest('PostRelease');
|
||||
await db.setExportReleased(SLUG, true);
|
||||
await db.fakeExportJob(SLUG, 'zip', 'done');
|
||||
await db.fakeExportJob(SLUG, 'html', 'done');
|
||||
|
||||
await signIn(page, g);
|
||||
const exportPage = new ExportPage(page);
|
||||
await exportPage.goto();
|
||||
|
||||
// The mirror of the test above: this is what proves the "before release" locators can
|
||||
// actually SEE a download button when one exists. Without this, a typo'd locator makes
|
||||
// the pre-release test unfalsifiable.
|
||||
await expect(exportPage.notAvailableBanner).toHaveCount(0);
|
||||
await expect(exportPage.zipDownloadButton).toBeVisible({ timeout: 10_000 });
|
||||
await expect(exportPage.zipDownloadButton).toBeEnabled();
|
||||
await expect(exportPage.htmlDownloadButton).toBeVisible();
|
||||
await expect(exportPage.htmlDownloadButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('export status API reflects released flag', async ({ guest, db }) => {
|
||||
const g = await guest('ReleaseQuery');
|
||||
|
||||
let res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', {
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
});
|
||||
let res = await fetch(
|
||||
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status',
|
||||
{
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
}
|
||||
);
|
||||
let body: any = await res.json();
|
||||
expect(body.released).toBe(false);
|
||||
|
||||
@@ -33,9 +72,12 @@ test.describe('Export — release and download', () => {
|
||||
await db.fakeExportJob(SLUG, 'zip', 'done');
|
||||
await db.fakeExportJob(SLUG, 'html', 'done');
|
||||
|
||||
res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', {
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
});
|
||||
res = await fetch(
|
||||
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status',
|
||||
{
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
}
|
||||
);
|
||||
body = await res.json();
|
||||
expect(body.released).toBe(true);
|
||||
expect(body.zip.status).toBe('done');
|
||||
@@ -54,27 +96,32 @@ test.describe('Export — release and download', () => {
|
||||
return (await res.json()).ticket;
|
||||
}
|
||||
|
||||
test('ZIP download 404s when the export is not yet marked ready', async ({ guest, db }) => {
|
||||
test('ZIP download 404s for a `done` job at a RETIRED epoch', async ({ guest, db }) => {
|
||||
const g = await guest('NotReady');
|
||||
// Released flag set, but export_zip_ready is still false → must refuse, never serve.
|
||||
// The event is released and the zip job says `done` — but the job carries a dead epoch, which
|
||||
// is exactly the state a reopen (or a superseded worker) leaves behind. Readiness is derived
|
||||
// from `job.epoch = event.export_epoch`, so this archive is NOT current and must never be
|
||||
// served. A 200 here would be the stale-keepsake bug leaking through the read path.
|
||||
await db.setExportReleased(SLUG, true);
|
||||
await db.fakeExportJob(SLUG, 'zip', 'done');
|
||||
await db.setExportZipReady(SLUG, false); // retire the job to a dead epoch
|
||||
const ticket = await mintTicket(g.jwt);
|
||||
|
||||
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
|
||||
// Pinned to 404 (not [404,200]): a 200 here would mean serving an export that was
|
||||
// never released for download — a data-exposure regression. This hits the
|
||||
// `!export_zip_ready` guard.
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('ZIP download 404s when marked ready but the file is missing on disk', async ({ guest, db }) => {
|
||||
test('ZIP download 404s when the job is current but the file is missing on disk', async ({
|
||||
guest,
|
||||
db,
|
||||
}) => {
|
||||
const g = await guest('ReadyNoFile');
|
||||
// Released AND ready, but no Gallery.zip on disk (we never ran a real export) →
|
||||
// the handler must 404 on the missing-file check, not 200/500 or serve a stale file.
|
||||
// Released, and the job is `done` at the LIVE epoch — but no archive was ever written (we
|
||||
// never ran a real export). The handler must 404 on the missing file, not 200/500 or serve
|
||||
// something stale.
|
||||
await db.setExportReleased(SLUG, true);
|
||||
await db.setExportZipReady(SLUG, true);
|
||||
await db.fakeExportJob(SLUG, 'zip', 'done');
|
||||
await db.setExportZipReady(SLUG, true);
|
||||
const ticket = await mintTicket(g.jwt);
|
||||
|
||||
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
|
||||
|
||||
51
e2e/specs/07-adversarial/admin-guest-displacement.spec.ts
Normal file
51
e2e/specs/07-adversarial/admin-guest-displacement.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Regression guard — privilege escalation via mixed auth storage.
|
||||
*
|
||||
* The admin JWT lives in sessionStorage; guest JWTs in localStorage; reads are
|
||||
* sessionStorage-first. So a guest login MUST displace any resident admin token — otherwise
|
||||
* a leftover admin session on a shared "event laptop" would shadow the guest's own token and
|
||||
* silently hand the next person who joins full admin rights. This exercises the real UI flow.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { JoinPage } from '../../page-objects';
|
||||
|
||||
function roleOf(page: import('@playwright/test').Page) {
|
||||
return page.evaluate(() => {
|
||||
const t = sessionStorage.getItem('eventsnap_jwt') ?? localStorage.getItem('eventsnap_jwt');
|
||||
if (!t) return null;
|
||||
try {
|
||||
return JSON.parse(atob(t.split('.')[1])).role;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('AuthZ — guest join displaces a resident admin session', () => {
|
||||
test('joining as a guest with an admin token resident in sessionStorage yields GUEST rights', async ({
|
||||
page,
|
||||
api,
|
||||
}) => {
|
||||
// Simulate an admin who logged in on a shared device and walked away without closing the
|
||||
// tab — their token sits in sessionStorage.
|
||||
const adminJwt = await api.adminLogin();
|
||||
await page.goto('/');
|
||||
await page.evaluate((jwt) => sessionStorage.setItem('eventsnap_jwt', jwt), adminJwt);
|
||||
|
||||
// A different person now joins as a guest in that same tab.
|
||||
const join = new JoinPage(page);
|
||||
await join.goto();
|
||||
await join.joinAs('DisplacementGuest');
|
||||
await join.continueToFeed();
|
||||
|
||||
// The effective identity must be the GUEST — not the leftover admin.
|
||||
expect(await roleOf(page)).toBe('guest');
|
||||
// The admin token must have been cleared from sessionStorage by the guest login.
|
||||
expect(await page.evaluate(() => sessionStorage.getItem('eventsnap_jwt'))).toBeNull();
|
||||
|
||||
// And the guest must NOT be able to reach the admin dashboard.
|
||||
await page.goto('/admin');
|
||||
await page.waitForURL(/admin\/login|join|feed/, { timeout: 5_000 });
|
||||
expect(page.url()).not.toMatch(/\/admin(\/)?$/);
|
||||
});
|
||||
});
|
||||
@@ -7,23 +7,29 @@
|
||||
* tampered signature, expired sessions, wrong role.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { ADMIN_PASSWORD } from '../../fixtures/api-client';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
/** RFC-4648 base64url with no padding. */
|
||||
function b64u(s: string) {
|
||||
return Buffer.from(s).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||
return Buffer.from(s)
|
||||
.toString('base64')
|
||||
.replace(/=+$/, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
test.describe('Adversarial — JWT', () => {
|
||||
test('alg:none token claiming admin role is rejected', async () => {
|
||||
const header = b64u(JSON.stringify({ alg: 'none', typ: 'JWT' }));
|
||||
const payload = b64u(JSON.stringify({
|
||||
sub: '00000000-0000-0000-0000-000000000000',
|
||||
role: 'admin',
|
||||
event_id: '00000000-0000-0000-0000-000000000000',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
}));
|
||||
const payload = b64u(
|
||||
JSON.stringify({
|
||||
sub: '00000000-0000-0000-0000-000000000000',
|
||||
role: 'admin',
|
||||
event_id: '00000000-0000-0000-0000-000000000000',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
})
|
||||
);
|
||||
const token = `${header}.${payload}.`;
|
||||
const res = await fetch(`${BASE}/api/v1/admin/config`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -43,7 +49,9 @@ test.describe('Adversarial — JWT', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('JWT with payload-tampered role=admin (re-encoded payload, original signature) is rejected', async ({ guest }) => {
|
||||
test('JWT with payload-tampered role=admin (re-encoded payload, original signature) is rejected', async ({
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('RolePromote');
|
||||
const parts = g.jwt.split('.');
|
||||
const original = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||
@@ -110,7 +118,9 @@ test.describe('Adversarial — PIN brute-force', () => {
|
||||
expect(correct.status).toBe(429);
|
||||
});
|
||||
|
||||
test('parallel wrong-PIN attempts may NOT all hit lockout (race-condition finding)', async ({ guest }) => {
|
||||
test('parallel wrong-PIN attempts still lock the account (counter is not lost to the race)', async ({
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('BruteParallel');
|
||||
const wrong = g.pin === '0000' ? '1111' : '0000';
|
||||
|
||||
@@ -124,30 +134,131 @@ test.describe('Adversarial — PIN brute-force', () => {
|
||||
)
|
||||
);
|
||||
const statuses = attempts.map((r) => r.status);
|
||||
expect(statuses.filter((s) => s === 200)).toHaveLength(0);
|
||||
// Documented behavior: lockout counter may race so not every status is 429.
|
||||
// Critical invariant: no attempt succeeded.
|
||||
if (!statuses.some((s) => s === 429)) {
|
||||
console.warn('[finding] PIN-attempt counter races under parallel requests — none hit lockout.');
|
||||
}
|
||||
expect(
|
||||
statuses.filter((s) => s === 200),
|
||||
'a wrong PIN must never authenticate'
|
||||
).toHaveLength(0);
|
||||
|
||||
// The in-flight requests all read `pin_locked_until` before any of them wrote it, so
|
||||
// *which* of the 10 come back 429 is genuinely racy and can't be asserted. What is NOT
|
||||
// racy — and is the property this test exists to guard — is the state left behind:
|
||||
// `failed_pin_attempts` is incremented with an atomic `SET x = x + 1 ... RETURNING`, so
|
||||
// 10 wrong PINs must push it past the 3-strike threshold and leave the account locked.
|
||||
//
|
||||
// We prove that with a follow-up request using the CORRECT pin: it must be refused with
|
||||
// 429 (locked), not 200. Delete the lockout counter and this line goes 200 → red.
|
||||
const correct = await fetch(`${BASE}/api/v1/recover`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: g.displayName, pin: g.pin }),
|
||||
});
|
||||
expect(
|
||||
correct.status,
|
||||
'after 10 wrong PINs the account must be locked, even for the right PIN'
|
||||
).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Adversarial — admin password brute-force', () => {
|
||||
test('repeated wrong passwords do NOT lock the admin (documented finding)', async () => {
|
||||
// The admin login handler does not currently implement lockout. This test
|
||||
// documents the behavior so any future change is intentional.
|
||||
const attempts = await Promise.all(
|
||||
Array.from({ length: 10 }, () =>
|
||||
fetch(`${BASE}/api/v1/admin/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'wrong-' + Math.random() }),
|
||||
})
|
||||
)
|
||||
);
|
||||
const statuses = attempts.map((r) => r.status);
|
||||
expect(statuses.every((s) => s === 401)).toBe(true);
|
||||
console.warn('[finding] /admin/login has no rate-limit or lockout — bcrypt cost is the only defense.');
|
||||
// These tests deliberately exhaust the admin-login limiter for the shared test IP. That creates a
|
||||
// chicken-and-egg for the NEXT test: its truncate auto-fixture must itself call admin_login before
|
||||
// it can reset the counter — so a still-full window would lock the fixture out with 429 before it
|
||||
// could clear anything. Disable the toggle here via patchConfig (which authenticates with a JWT,
|
||||
// NOT the rate-limited admin_login path), so the next login is clear; truncate then wipes the map.
|
||||
// Runs even if the test body failed, so a failure can't poison the rest of the run.
|
||||
test.afterEach(async ({ api, adminToken }) => {
|
||||
await api.patchConfig(adminToken, { admin_login_rate_enabled: 'false' });
|
||||
});
|
||||
|
||||
const tryLogin = (password: string) =>
|
||||
fetch(`${BASE}/api/v1/admin/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
test('admin login is IP rate-limited: a burst of wrong passwords starts returning 429', async ({
|
||||
api,
|
||||
adminToken,
|
||||
}) => {
|
||||
// `admin_login` HAS a 5/min/IP throttle (auth/handlers.rs), gated by the master
|
||||
// `rate_limits_enabled` + `admin_login_rate_enabled`. Both default TRUE in production — but the
|
||||
// e2e reseed turns the master switch OFF for every test, so this defence ran in ZERO tests.
|
||||
// (The previous version of this test observed all-401 under that disabled switch and wrongly
|
||||
// "documented" that no rate limit exists.) Turn it on and prove it.
|
||||
//
|
||||
// patchConfig uses the already-minted adminToken (a JWT), so enabling the limiter does not lock
|
||||
// us out of configuring it.
|
||||
await api.patchConfig(adminToken, {
|
||||
rate_limits_enabled: 'true',
|
||||
admin_login_rate_enabled: 'true',
|
||||
});
|
||||
|
||||
// Sequential, not parallel: the counter increments deterministically so the 429 is not itself
|
||||
// subject to the counter race the PIN test covers.
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
statuses.push((await tryLogin('wrong-' + i)).status);
|
||||
if (statuses[i] === 429) break;
|
||||
}
|
||||
|
||||
// The password path actually ran (budget existed) before the limiter engaged...
|
||||
expect(
|
||||
statuses[0],
|
||||
'first attempt should be a normal wrong-password 401, not a spurious 429'
|
||||
).toBe(401);
|
||||
// ...and the limiter DID engage within the window. Delete the throttle and this is never true.
|
||||
expect(
|
||||
statuses.some((s) => s === 429),
|
||||
'a burst of wrong admin passwords from one IP must start being rate-limited (429)'
|
||||
).toBe(true);
|
||||
// No wrong password ever authenticated.
|
||||
expect(statuses.some((s) => s === 200)).toBe(false);
|
||||
});
|
||||
|
||||
test('once throttled, even the CORRECT admin password is refused (it is an IP limit, not a password check)', async ({
|
||||
api,
|
||||
adminToken,
|
||||
}) => {
|
||||
// This is the assertion that makes the test non-vacuous: it isolates the RATE LIMIT from the
|
||||
// password logic. If the throttle were removed, the correct password would return 200 here.
|
||||
await api.patchConfig(adminToken, {
|
||||
rate_limits_enabled: 'true',
|
||||
admin_login_rate_enabled: 'true',
|
||||
});
|
||||
|
||||
// Exhaust the window with wrong passwords until throttled.
|
||||
let throttled = false;
|
||||
for (let i = 0; i < 10 && !throttled; i++) {
|
||||
throttled = (await tryLogin('wrong-' + i)).status === 429;
|
||||
}
|
||||
expect(throttled, 'the IP should be throttled after a burst').toBe(true);
|
||||
|
||||
// The right password, while throttled, must STILL be refused — the limiter is checked before
|
||||
// the bcrypt verify, so a valid credential does not buy a way around a brute-force lockout.
|
||||
expect(
|
||||
(await tryLogin(ADMIN_PASSWORD)).status,
|
||||
'a throttled IP is refused even with the correct password'
|
||||
).toBe(429);
|
||||
});
|
||||
|
||||
test('the throttle is gated: with the limiter disabled, a burst is NOT rate-limited', async ({
|
||||
api,
|
||||
adminToken,
|
||||
}) => {
|
||||
// The mirror of the above — proves the toggle actually gates the behaviour (and documents that
|
||||
// the e2e default really is "off", which is why every OTHER admin test can hammer login freely).
|
||||
await api.patchConfig(adminToken, {
|
||||
rate_limits_enabled: 'true',
|
||||
admin_login_rate_enabled: 'false',
|
||||
});
|
||||
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 8; i++) statuses.push((await tryLogin('wrong-' + i)).status);
|
||||
|
||||
expect(
|
||||
statuses.every((s) => s === 401),
|
||||
'with admin_login_rate_enabled=false no attempt should be 429'
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload, seedComment, listComments, findFeedRow } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Adversarial — deep authorization', () => {
|
||||
// IDOR: user B must not be able to delete user A's REAL comment. This exercises the
|
||||
// ownership guard (`comment.user_id != auth.user_id` → 403) — the previous version fired
|
||||
// at the all-zeros UUID, which 404s at the lookup BEFORE that guard runs, so it never
|
||||
// tested authorization at all.
|
||||
test('user B cannot delete user A\'s comment (real resource → 403, comment survives)', async ({ guest }) => {
|
||||
test("user B cannot delete user A's comment (real resource → 403, comment survives)", async ({
|
||||
guest,
|
||||
}) => {
|
||||
const a = await guest('CommentOwnerA');
|
||||
const b = await guest('AttackerB');
|
||||
|
||||
@@ -42,7 +43,7 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
});
|
||||
|
||||
// IDOR: user B must not be able to delete user A's REAL upload.
|
||||
test('user B cannot delete user A\'s upload (403, upload survives)', async ({ guest, db }) => {
|
||||
test("user B cannot delete user A's upload (403, upload survives)", async ({ guest, db }) => {
|
||||
const a = await guest('UploadOwnerA');
|
||||
const b = await guest('AttackerB2');
|
||||
|
||||
@@ -60,7 +61,7 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
});
|
||||
|
||||
// IDOR: user B must not be able to edit (re-caption / re-tag) user A's upload.
|
||||
test('user B cannot edit user A\'s upload caption (403, caption unchanged)', async ({ guest }) => {
|
||||
test("user B cannot edit user A's upload caption (403, caption unchanged)", async ({ guest }) => {
|
||||
const a = await guest('UploadOwnerA2');
|
||||
const b = await guest('AttackerB3');
|
||||
|
||||
@@ -75,37 +76,55 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
// No state change: the caption A set is intact.
|
||||
const feedRes = await fetch(`${BASE}/api/v1/feed`, { headers: { Authorization: `Bearer ${a.jwt}` } });
|
||||
const feedRes = await fetch(`${BASE}/api/v1/feed`, {
|
||||
headers: { Authorization: `Bearer ${a.jwt}` },
|
||||
});
|
||||
const row = findFeedRow(await feedRes.json(), uploadId);
|
||||
expect(row?.caption).toBe('original caption');
|
||||
});
|
||||
|
||||
// These two fire at a REAL upload, and demand exactly 403.
|
||||
//
|
||||
// They used to POST to the all-zeros UUID and accept `[403, 404]`. Both handlers check
|
||||
// `user.is_banned` BEFORE they look the upload up (social.rs) — so the 404 came from the
|
||||
// *lookup*, not the guard. Delete the ban check entirely and the request still 404s on the
|
||||
// nonexistent upload, and both tests still passed. They were the only coverage of
|
||||
// ban-blocks-like and ban-blocks-comment, and they guarded nothing.
|
||||
test('banned user cannot toggle a like', async ({ api, host, guest }) => {
|
||||
const target = await guest('BannedLike');
|
||||
await api.banUser(host.jwt, target.userId, false);
|
||||
const uploadId = await seedUpload(host.jwt, { caption: 'likeable' });
|
||||
await api.banUser(host.jwt, target.userId);
|
||||
|
||||
const res = await fetch(`${BASE}/api/v1/upload/00000000-0000-0000-0000-000000000000/like`, {
|
||||
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${target.jwt}` },
|
||||
});
|
||||
expect([403, 404]).toContain(res.status);
|
||||
expect(res.status, 'a banned user must be Forbidden, not merely miss the resource').toBe(403);
|
||||
});
|
||||
|
||||
test('banned user cannot post a comment', async ({ api, host, guest }) => {
|
||||
const target = await guest('BannedComment');
|
||||
await api.banUser(host.jwt, target.userId, false);
|
||||
const uploadId = await seedUpload(host.jwt, { caption: 'commentable' });
|
||||
await api.banUser(host.jwt, target.userId);
|
||||
|
||||
const res = await fetch(`${BASE}/api/v1/upload/00000000-0000-0000-0000-000000000000/comments`, {
|
||||
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${target.jwt}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: 'should be rejected' }),
|
||||
});
|
||||
expect([403, 404]).toContain(res.status);
|
||||
expect(res.status, 'a banned user must be Forbidden, not merely miss the resource').toBe(403);
|
||||
|
||||
// ...and nothing was written.
|
||||
expect(await listComments(host.jwt, uploadId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('banned user can still read the feed (read-only access preserved)', async ({ api, host, guest }) => {
|
||||
test('banned user can still read the feed (read-only access preserved)', async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
}) => {
|
||||
const target = await guest('BannedRead');
|
||||
await api.banUser(host.jwt, target.userId, false);
|
||||
await api.banUser(host.jwt, target.userId);
|
||||
|
||||
const res = await fetch(`${BASE}/api/v1/feed`, {
|
||||
headers: { Authorization: `Bearer ${target.jwt}` },
|
||||
@@ -114,7 +133,11 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('host cannot delete another host\'s session via /api/v1/session', async ({ api, host, guest }) => {
|
||||
test("host cannot delete another host's session via /api/v1/session", async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
}) => {
|
||||
const otherHost = await guest('OtherHost');
|
||||
// Promote so they have a host JWT to play with.
|
||||
await api.setRole(host.jwt, otherHost.userId, 'host');
|
||||
@@ -130,15 +153,40 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
expect(stillWorks.status).toBe(200);
|
||||
});
|
||||
|
||||
test('promote endpoint cannot be used to make oneself admin', async ({ host }) => {
|
||||
const res = await fetch(`${BASE}/api/v1/host/users/${'00000000-0000-0000-0000-000000000000'}/role`, {
|
||||
// Privilege escalation, tested against REAL targets.
|
||||
//
|
||||
// The old version PATCHed the all-zeros UUID and accepted `[400, 403, 404]`. The role whitelist
|
||||
// rejects "admin" with a 400 before the target is ever looked up — so adding `"admin"` to the
|
||||
// whitelist would make the request 404 on the nonexistent user instead, which was in the accepted
|
||||
// list. It never promoted anyone, never targeted *oneself*, and could not detect escalation.
|
||||
test('a host cannot promote a real guest to admin', async ({ api, host, guest, adminToken }) => {
|
||||
const victim = await guest('EscalationTarget');
|
||||
|
||||
const res = await fetch(`${BASE}/api/v1/host/users/${victim.userId}/role`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${host.jwt}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: 'admin' }),
|
||||
});
|
||||
// 400 (invalid role for host-callable endpoint) or 403/404.
|
||||
expect([400, 403, 404]).toContain(res.status);
|
||||
// Critically, NOT 200/204.
|
||||
expect([200, 204]).not.toContain(res.status);
|
||||
expect(res.status).not.toBe(204);
|
||||
expect(res.status).not.toBe(200);
|
||||
|
||||
// The assertion that actually matters: nobody got promoted.
|
||||
const users = await api.listUsers(adminToken);
|
||||
const row = users.find((u: any) => u.id === victim.userId);
|
||||
expect(row?.role, 'the guest must NOT have become an admin').not.toBe('admin');
|
||||
});
|
||||
|
||||
test('a host cannot promote THEMSELVES to admin', async ({ api, host, adminToken }) => {
|
||||
const res = await fetch(`${BASE}/api/v1/host/users/${host.userId}/role`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${host.jwt}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: 'admin' }),
|
||||
});
|
||||
expect(res.status).not.toBe(204);
|
||||
expect(res.status).not.toBe(200);
|
||||
|
||||
const users = await api.listUsers(adminToken);
|
||||
const me = users.find((u: any) => u.id === host.userId);
|
||||
expect(me?.role, 'the host must NOT have self-promoted to admin').not.toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { mintSseTicket } from '../../helpers/sse';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Adversarial — small-scale abuse', () => {
|
||||
// Note: the truncate auto-fixture resets every rate-limit toggle back to false
|
||||
@@ -30,19 +30,52 @@ test.describe('Adversarial — small-scale abuse', () => {
|
||||
expect(statuses.some((s) => s === 201 || s === 409)).toBe(true);
|
||||
});
|
||||
|
||||
test('10 MB comment body is rejected (multipart-less endpoint)', async ({ guest }) => {
|
||||
const g = await guest('BigComment');
|
||||
const huge = 'A'.repeat(10 * 1024 * 1024);
|
||||
const res = await fetch(`${BASE}/api/v1/upload/00000000-0000-0000-0000-000000000000/comments`, {
|
||||
// The comment body cap lives in [backend/src/handlers/social.rs] `add_comment`:
|
||||
// if text_chars == 0 || text_chars > 500 → 400
|
||||
// It must be exercised against a REAL upload: the handler looks the upload up (and
|
||||
// 404s) *before* it reaches the length check, so posting to a non-existent id proves
|
||||
// nothing about the cap.
|
||||
async function postComment(jwt: string, uploadId: string, body: string) {
|
||||
return fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: huge }),
|
||||
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
// 400 (length cap), 404 (no such upload), 413 (payload too large), 429 (rate-limited),
|
||||
// or 502 (Caddy rejected the body before it reached the backend) — all fine.
|
||||
expect([400, 404, 413, 429, 502]).toContain(res.status);
|
||||
// Not 200 — that would mean we accepted a 10 MB comment.
|
||||
expect(res.status).not.toBe(200);
|
||||
}
|
||||
|
||||
test('comment body over the 500-char cap is rejected with 400', async ({ guest }) => {
|
||||
const g = await guest('LongComment');
|
||||
const uploadId = await seedUpload(g.jwt);
|
||||
|
||||
const res = await postComment(g.jwt, uploadId, 'A'.repeat(501));
|
||||
expect(res.status, '501 chars must be rejected by the length cap').toBe(400);
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
expect((json.message ?? '').toLowerCase()).toMatch(/500 zeichen/);
|
||||
});
|
||||
|
||||
test('comment body exactly at the 500-char cap is accepted', async ({ guest }) => {
|
||||
const g = await guest('MaxComment');
|
||||
const uploadId = await seedUpload(g.jwt);
|
||||
|
||||
// The boundary must be inclusive — otherwise the "cap" is really 499 and the
|
||||
// rejection test above would also pass on an off-by-one implementation.
|
||||
const res = await postComment(g.jwt, uploadId, 'A'.repeat(500));
|
||||
expect(res.status, '500 chars is the documented maximum and must be accepted').toBe(201);
|
||||
});
|
||||
|
||||
test('10 MB comment body never reaches the handler (body-size limit rejects it)', async ({
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('BigComment');
|
||||
const uploadId = await seedUpload(g.jwt);
|
||||
const huge = 'A'.repeat(10 * 1024 * 1024);
|
||||
|
||||
const res = await postComment(g.jwt, uploadId, huge);
|
||||
// This asserts ONLY what it can prove: a 10 MB JSON body is refused somewhere on the
|
||||
// path (Caddy's request-body limit → 502/413, or the backend's own body limit → 413,
|
||||
// or the 500-char cap if it does get through → 400). The upload exists, so a 404 here
|
||||
// would be a bug, and a 201 would mean we stored a 10 MB comment.
|
||||
expect([400, 413, 502]).toContain(res.status);
|
||||
});
|
||||
|
||||
test('SSE: 10 concurrent streams from one user do not crash the server', async ({ guest }) => {
|
||||
@@ -55,7 +88,9 @@ test.describe('Adversarial — small-scale abuse', () => {
|
||||
|
||||
const controllers = tickets.map(() => new AbortController());
|
||||
const requests = tickets.map((ticket, i) =>
|
||||
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, { signal: controllers[i].signal })
|
||||
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, {
|
||||
signal: controllers[i].signal,
|
||||
})
|
||||
);
|
||||
const responses = await Promise.all(requests);
|
||||
// All accepted (or some rate-limited — both fine).
|
||||
|
||||
Binary file not shown.
@@ -6,8 +6,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Media gating — moderation revokes preview access (F2)', () => {
|
||||
test('preview served via gated alias, blocked directly, and 404 after delete', async ({
|
||||
@@ -56,4 +55,42 @@ test.describe('Media gating — moderation revokes preview access (F2)', () => {
|
||||
const afterDelete = await fetch(`${BASE}/api/v1/upload/${id}/preview`);
|
||||
expect(afterDelete.status, 'moderation must revoke preview access').toBe(404);
|
||||
});
|
||||
|
||||
test('the preview is revoked when the UPLOADER is banned (not just on delete)', async ({
|
||||
host,
|
||||
api,
|
||||
guest,
|
||||
}) => {
|
||||
test.setTimeout(30_000);
|
||||
// The header of this file claims delete AND ban-hide both revoke access, but only delete was
|
||||
// ever exercised. A ban hides the user's content everywhere (the visibility check filters
|
||||
// `is_banned` inside `find_by_id_visible`, which gates preview AND thumbnail identically), and
|
||||
// its whole point is that a direct-URL holder loses the image — so it must 404 the gated
|
||||
// preview too, exactly like a delete.
|
||||
const offender = await guest('BannedUploader');
|
||||
const id = await seedUpload(offender.jwt, { caption: 'to be hidden' });
|
||||
|
||||
// Wait for the compression worker to produce the preview.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const row = (await api.getFeed(host.jwt)).uploads?.find((u: any) => u.id === id);
|
||||
return row?.preview_url;
|
||||
},
|
||||
{ timeout: 20_000, intervals: [500] }
|
||||
)
|
||||
.toBe(`/api/v1/upload/${id}/preview`);
|
||||
|
||||
// Served while the uploader is in good standing.
|
||||
expect((await fetch(`${BASE}/api/v1/upload/${id}/preview`)).status).toBe(200);
|
||||
|
||||
// Ban the uploader (default: hide their uploads).
|
||||
await api.banUser(host.jwt, offender.userId);
|
||||
|
||||
// Must now 404 — the direct-URL holder loses the image, same as a takedown.
|
||||
expect(
|
||||
(await fetch(`${BASE}/api/v1/upload/${id}/preview`)).status,
|
||||
"a banned uploader's preview must be revoked"
|
||||
).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
* still work.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
function patchRole(token: string, userId: string, role: string) {
|
||||
return fetch(`${BASE}/api/v1/host/users/${userId}/role`, {
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { mintSseTicket, openStream } from '../../helpers/sse';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Adversarial — SSE ticket abuse', () => {
|
||||
test('minting a ticket requires authentication', async () => {
|
||||
|
||||
@@ -14,39 +14,56 @@ test.describe('Adversarial — UI render escape', () => {
|
||||
const r = await api.join(payload);
|
||||
|
||||
await page.goto('/');
|
||||
await page.evaluate(({ j, u, n, p }) => {
|
||||
localStorage.setItem('eventsnap_jwt', j);
|
||||
localStorage.setItem('eventsnap_user_id', u);
|
||||
localStorage.setItem('eventsnap_display_name', n);
|
||||
localStorage.setItem('eventsnap_pin', p);
|
||||
}, { j: r.jwt, u: r.user_id, n: payload, p: r.pin });
|
||||
await page.evaluate(
|
||||
({ j, u, n, p }) => {
|
||||
localStorage.setItem('eventsnap_jwt', j);
|
||||
localStorage.setItem('eventsnap_user_id', u);
|
||||
localStorage.setItem('eventsnap_display_name', n);
|
||||
localStorage.setItem('eventsnap_pin', p);
|
||||
},
|
||||
{ j: r.jwt, u: r.user_id, n: payload, p: r.pin }
|
||||
);
|
||||
|
||||
page.on('dialog', (d) => {
|
||||
throw new Error(`Dialog fired: ${d.message()}`);
|
||||
});
|
||||
|
||||
await page.goto('/account');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Render guard FIRST. `domcontentloaded` fires before Svelte hydrates, so asserting
|
||||
// the *absence* of a <b> at that point passes on a page that never rendered the name
|
||||
// at all — a false green on an XSS test. Prove the payload actually reached the DOM
|
||||
// (as escaped text) before concluding anything about how it was rendered.
|
||||
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const fired = await page.evaluate(() => (window as any).__xssFired === true);
|
||||
expect(fired).toBe(false);
|
||||
|
||||
// <b> tag inside the name should also not render as bold — Svelte escapes the entire string.
|
||||
const boldCount = await page.locator('b:has-text("BOLD")').count();
|
||||
expect(boldCount).toBe(0);
|
||||
// <b> tag inside the name should also not render as bold — Svelte escapes the entire
|
||||
// string. toHaveCount auto-retries, so this can't win by racing hydration.
|
||||
await expect(page.locator('b:has-text("BOLD")')).toHaveCount(0);
|
||||
await expect(page.locator('script:has-text("__xssFired")')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('rendering of a known SQL-injection-shaped name does not break the page', async ({ page, api }) => {
|
||||
test('rendering of a known SQL-injection-shaped name does not break the page', async ({
|
||||
page,
|
||||
api,
|
||||
}) => {
|
||||
const payload = `'); DROP TABLE users; --`;
|
||||
const r = await api.join(payload);
|
||||
|
||||
await page.goto('/');
|
||||
await page.evaluate(({ j, u, n, p }) => {
|
||||
localStorage.setItem('eventsnap_jwt', j);
|
||||
localStorage.setItem('eventsnap_user_id', u);
|
||||
localStorage.setItem('eventsnap_display_name', n);
|
||||
localStorage.setItem('eventsnap_pin', p);
|
||||
}, { j: r.jwt, u: r.user_id, n: payload, p: r.pin });
|
||||
await page.evaluate(
|
||||
({ j, u, n, p }) => {
|
||||
localStorage.setItem('eventsnap_jwt', j);
|
||||
localStorage.setItem('eventsnap_user_id', u);
|
||||
localStorage.setItem('eventsnap_display_name', n);
|
||||
localStorage.setItem('eventsnap_pin', p);
|
||||
},
|
||||
{ j: r.jwt, u: r.user_id, n: payload, p: r.pin }
|
||||
);
|
||||
|
||||
await page.goto('/account');
|
||||
// Page renders.
|
||||
|
||||
@@ -13,18 +13,34 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload, seedComment } from '../../helpers/seed';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
|
||||
/**
|
||||
* Every payload sets `window.__x = 1` if it executes. The marker is deliberately terse:
|
||||
* the join handler caps display names at 50 chars, and a payload that trips that cap is
|
||||
* rejected at the API — which means it is NEVER stored and NEVER rendered, so the test
|
||||
* that "nothing executed" proves nothing at all. Each payload below is < 50 chars, so it
|
||||
* survives the join and actually reaches the render sink under test.
|
||||
*/
|
||||
const XSS_PAYLOADS = [
|
||||
`<script>window.__xssFired=true</script>`,
|
||||
`<img src=x onerror="window.__xssFired=true">`,
|
||||
`"><svg onload="window.__xssFired=true">`,
|
||||
`<iframe src="javascript:window.parent.__xssFired=true"></iframe>`,
|
||||
`javascript:window.__xssFired=true`,
|
||||
`<a href="javascript:window.__xssFired=true">click</a>`,
|
||||
`<script>window.__x=1</script>`, // 29
|
||||
`<img src=x onerror="window.__x=1">`, // 34
|
||||
`"><svg onload="window.__x=1">`, // 29
|
||||
`<iframe src="javascript:parent.__x=1"></iframe>`, // 47
|
||||
`javascript:window.__x=1`, // 23
|
||||
`<a href="javascript:window.__x=1">c</a>`, // 39
|
||||
];
|
||||
|
||||
// Guard the invariant the payloads depend on: if the display-name cap ever changes, or a
|
||||
// payload is edited past it, we want a loud failure here rather than six silent no-ops.
|
||||
const NAME_MAX = 50;
|
||||
for (const p of XSS_PAYLOADS) {
|
||||
if (p.length > NAME_MAX)
|
||||
throw new Error(
|
||||
`XSS payload exceeds the ${NAME_MAX}-char display-name cap and would never be stored: ${p}`
|
||||
);
|
||||
}
|
||||
|
||||
const SQLI_PAYLOADS = [
|
||||
`'; DROP TABLE "user"; --`,
|
||||
`' OR 1=1 --`,
|
||||
@@ -34,20 +50,14 @@ const SQLI_PAYLOADS = [
|
||||
|
||||
test.describe('Adversarial — input injection (display name)', () => {
|
||||
for (const payload of XSS_PAYLOADS) {
|
||||
test(`name with XSS payload ${JSON.stringify(payload).slice(0, 40)} never executes`, async ({ api, page }) => {
|
||||
// Payloads > 50 chars are rejected by the join handler — that's a valid defense.
|
||||
// Only if the API accepts the payload do we proceed to assert it never executes
|
||||
// when rendered.
|
||||
let res;
|
||||
try {
|
||||
res = await api.join(payload);
|
||||
} catch (e: any) {
|
||||
if (/→ 400/.test(e.message ?? '')) {
|
||||
// Defended at the API. No need to render.
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
test(`name with XSS payload ${JSON.stringify(payload).slice(0, 40)} never executes`, async ({
|
||||
api,
|
||||
page,
|
||||
}) => {
|
||||
// No try/catch escape hatch: every payload is short enough to be accepted, so a
|
||||
// rejection here is a real failure (the payload would never be rendered, and the
|
||||
// "nothing executed" assertions below would be vacuous).
|
||||
const res = await api.join(payload);
|
||||
expect(res.jwt).toBeTruthy();
|
||||
|
||||
// Render the name in the account page by signing in.
|
||||
@@ -72,14 +82,16 @@ test.describe('Adversarial — input injection (display name)', () => {
|
||||
|
||||
// Render guard: confirm the payload actually reached the DOM as escaped text,
|
||||
// so a "nothing fired" pass can't be because the name was never rendered.
|
||||
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const fired = await page.evaluate(() => (window as any).__xssFired === true);
|
||||
expect(fired, 'window.__xssFired should never be set').toBe(false);
|
||||
const fired = await page.evaluate(() => (window as any).__x === 1);
|
||||
expect(fired, 'window.__x should never be set').toBe(false);
|
||||
expect(dialogs, 'no dialogs should appear').toHaveLength(0);
|
||||
|
||||
// Inline script tag in the displayed name should be rendered as text, not parsed.
|
||||
const scriptCount = await page.locator('script:has-text("window.__xssFired")').count();
|
||||
const scriptCount = await page.locator('script:has-text("window.__x")').count();
|
||||
expect(scriptCount, 'no executable script tags rendered from name').toBe(0);
|
||||
});
|
||||
}
|
||||
@@ -87,7 +99,11 @@ test.describe('Adversarial — input injection (display name)', () => {
|
||||
|
||||
test.describe('Adversarial — stored XSS (caption)', () => {
|
||||
for (const payload of XSS_PAYLOADS) {
|
||||
test(`caption with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, page, signIn }) => {
|
||||
test(`caption with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({
|
||||
guest,
|
||||
page,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('CapXss');
|
||||
// A trailing marker lets us wait until the caption has actually rendered before
|
||||
// asserting nothing fired — otherwise a caption that never rendered would pass vacuously.
|
||||
@@ -95,17 +111,30 @@ test.describe('Adversarial — stored XSS (caption)', () => {
|
||||
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
|
||||
const dialogs: string[] = [];
|
||||
page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
|
||||
page.on('dialog', (d) => {
|
||||
dialogs.push(d.message());
|
||||
d.dismiss().catch(() => {});
|
||||
});
|
||||
|
||||
await signIn(page, g);
|
||||
// Wait for the caption text to land in the DOM (escaped, as literal text).
|
||||
await expect(page.getByText('CAPMARK', { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('CAPMARK', { exact: false }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
expect(await page.evaluate(() => (window as any).__xssFired === true), 'caption XSS must not fire').toBe(false);
|
||||
expect(
|
||||
await page.evaluate(() => (window as any).__x === 1),
|
||||
'caption XSS must not fire'
|
||||
).toBe(false);
|
||||
expect(dialogs, 'no dialogs from a caption').toHaveLength(0);
|
||||
// The payload must be inert text, not a live element / script.
|
||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from caption').toBe(0);
|
||||
expect(await page.locator('script:has-text("__xssFired")').count(), 'no executable script from caption').toBe(0);
|
||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from caption').toBe(
|
||||
0
|
||||
);
|
||||
expect(
|
||||
await page.locator('script:has-text("window.__x")').count(),
|
||||
'no executable script from caption'
|
||||
).toBe(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -113,23 +142,30 @@ test.describe('Adversarial — stored XSS (caption)', () => {
|
||||
test.describe('Adversarial — stored XSS (comment)', () => {
|
||||
// The two payloads that actually execute on render (script injection via innerHTML
|
||||
// does not) — enough to prove the comment body is escaped without a slow 6× lightbox loop.
|
||||
const COMMENT_PAYLOADS = [
|
||||
`<img src=x onerror="window.__xssFired=true">`,
|
||||
`"><svg onload="window.__xssFired=true">`,
|
||||
];
|
||||
const COMMENT_PAYLOADS = [`<img src=x onerror="window.__x=1">`, `"><svg onload="window.__x=1">`];
|
||||
for (const payload of COMMENT_PAYLOADS) {
|
||||
test(`comment with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, page, signIn }) => {
|
||||
test(`comment with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({
|
||||
guest,
|
||||
page,
|
||||
signIn,
|
||||
}) => {
|
||||
const author = await guest('CmtXss');
|
||||
const id = await seedUpload(author.jwt, { caption: 'pic CAPMARK' });
|
||||
// Post the XSS comment via the API (verbatim storage).
|
||||
await seedComment(author.jwt, id, `${payload} CMTMARK`);
|
||||
|
||||
const dialogs: string[] = [];
|
||||
page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
|
||||
page.on('dialog', (d) => {
|
||||
dialogs.push(d.message());
|
||||
d.dismiss().catch(() => {});
|
||||
});
|
||||
|
||||
await signIn(page, author);
|
||||
// Open the lightbox (which loads + renders comments).
|
||||
const imageButton = page.locator('article').filter({ hasText: 'CAPMARK' }).first()
|
||||
const imageButton = page
|
||||
.locator('article')
|
||||
.filter({ hasText: 'CAPMARK' })
|
||||
.first()
|
||||
.getByRole('button', { name: 'Bild vergrößern' });
|
||||
await expect(imageButton).toBeVisible({ timeout: 10_000 });
|
||||
await imageButton.click();
|
||||
@@ -137,18 +173,28 @@ test.describe('Adversarial — stored XSS (comment)', () => {
|
||||
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
|
||||
await expect(lightbox).toBeVisible();
|
||||
// Wait until the comment (marker) has rendered.
|
||||
await expect(lightbox.getByText('CMTMARK', { exact: false })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(lightbox.getByText('CMTMARK', { exact: false })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
expect(await page.evaluate(() => (window as any).__xssFired === true), 'comment XSS must not fire').toBe(false);
|
||||
expect(
|
||||
await page.evaluate(() => (window as any).__x === 1),
|
||||
'comment XSS must not fire'
|
||||
).toBe(false);
|
||||
expect(dialogs, 'no dialogs from a comment').toHaveLength(0);
|
||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from comment').toBe(0);
|
||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from comment').toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Adversarial — input injection (SQL-injection patterns)', () => {
|
||||
for (const payload of SQLI_PAYLOADS) {
|
||||
test(`SQL-shaped name ${JSON.stringify(payload).slice(0, 40)} round-trips without breaking the DB`, async ({ api, adminToken }) => {
|
||||
test(`SQL-shaped name ${JSON.stringify(payload).slice(0, 40)} round-trips without breaking the DB`, async ({
|
||||
api,
|
||||
adminToken,
|
||||
}) => {
|
||||
const res = await api.join(payload);
|
||||
expect(res.jwt).toBeTruthy();
|
||||
|
||||
@@ -191,7 +237,10 @@ test.describe('Adversarial — input length & encoding', () => {
|
||||
expect([400, 201, 409]).toContain(res.status);
|
||||
});
|
||||
|
||||
test('Unicode RTL override character in name does not corrupt rendering', async ({ api, page }) => {
|
||||
test('Unicode RTL override character in name does not corrupt rendering', async ({
|
||||
api,
|
||||
page,
|
||||
}) => {
|
||||
const rtlName = `AliceeciVlA`; // U+202E RIGHT-TO-LEFT OVERRIDE
|
||||
const r = await api.join(rtlName);
|
||||
expect(r.user_id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
|
||||
@@ -16,7 +16,11 @@ test.describe('Browser chaos — environment', () => {
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('localStorage quota exhausted — writing JWT does not crash the app', async ({ page, guest, signIn }) => {
|
||||
test('localStorage quota exhausted — writing JWT does not crash the app', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('QuotaFull');
|
||||
|
||||
// Pre-fill localStorage with junk to push us near the quota.
|
||||
@@ -46,7 +50,11 @@ test.describe('Browser chaos — environment', () => {
|
||||
expect(errors.filter((e) => !/storage|quota/i.test(e.message))).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('hostile extension simulation: CSS hiding bottom nav does not break navigation', async ({ page, guest, signIn }) => {
|
||||
test('hostile extension simulation: CSS hiding bottom nav does not break navigation', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('HostileCss');
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
@@ -59,7 +67,11 @@ test.describe('Browser chaos — environment', () => {
|
||||
await expect(page).toHaveURL(/\/account$/);
|
||||
});
|
||||
|
||||
test('clock skew: browser ahead by 1 hour — JWT still valid (no nbf claim)', async ({ page, guest, signIn }) => {
|
||||
test('clock skew: browser ahead by 1 hour — JWT still valid (no nbf claim)', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('ClockSkew');
|
||||
|
||||
// Override Date.now() to be 1h in the future BEFORE the JWT check.
|
||||
@@ -74,7 +86,11 @@ test.describe('Browser chaos — environment', () => {
|
||||
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('clock skew: browser behind by 2 days — JWT exp may be in the past, app handles 401', async ({ page, guest, signIn }) => {
|
||||
test('clock skew: browser behind by 2 days — JWT exp may be in the past, app handles 401', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('ClockBack');
|
||||
|
||||
await page.addInitScript(() => {
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
test.describe('Browser chaos — IndexedDB', () => {
|
||||
test('IndexedDB cleared mid-session does not break navigation', async ({ page, guest, signIn }) => {
|
||||
test('IndexedDB cleared mid-session does not break navigation', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('IdbPurge');
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
@@ -16,11 +20,12 @@ test.describe('Browser chaos — IndexedDB', () => {
|
||||
// Drop every IndexedDB database the app might use.
|
||||
const dbs = (await (indexedDB as any).databases?.()) ?? [];
|
||||
await Promise.all(
|
||||
dbs.map(({ name }: { name: string }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(name);
|
||||
req.onsuccess = req.onerror = req.onblocked = () => resolve();
|
||||
})
|
||||
dbs.map(
|
||||
({ name }: { name: string }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(name);
|
||||
req.onsuccess = req.onerror = req.onblocked = () => resolve();
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user