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
|
working-directory: ./e2e
|
||||||
run: npm run test:e2e -- --project=chromium-desktop
|
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
|
- name: Upload Playwright report
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
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
|
## 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.
|
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.
|
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 std::time::Duration;
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::Json;
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -39,8 +39,11 @@ pub async fn join(
|
|||||||
let ip = client_ip(&headers, "unknown");
|
let ip = client_ip(&headers, "unknown");
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
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;
|
let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await;
|
||||||
if rate_limits_on && join_rate_on
|
if rate_limits_on
|
||||||
&& !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60))
|
&& join_rate_on
|
||||||
|
&& !state
|
||||||
|
.rate_limiter
|
||||||
|
.check(format!("join:{ip}"), 5, Duration::from_secs(60))
|
||||||
{
|
{
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
@@ -80,8 +83,7 @@ pub async fn join(
|
|||||||
|
|
||||||
// Generate a 4-digit PIN
|
// Generate a 4-digit PIN
|
||||||
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
||||||
let pin_hash =
|
let pin_hash = bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||||
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
|
// 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
|
// pass it, and the DB's unique index then rejects the loser. Map that unique
|
||||||
@@ -178,8 +180,7 @@ pub async fn recover(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||||
|
|
||||||
let users =
|
let users = User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
|
||||||
User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
|
|
||||||
|
|
||||||
if users.is_empty() {
|
if users.is_empty() {
|
||||||
// No user with this name. Run a throwaway bcrypt verify so this branch takes
|
// 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?;
|
User::reset_pin_attempts(&state.pool, user.id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
|
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash).unwrap_or(false);
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if pin_matches {
|
if pin_matches {
|
||||||
// Reset failed attempts on success
|
// Reset failed attempts on success
|
||||||
@@ -289,13 +289,13 @@ pub async fn admin_login(
|
|||||||
// honest typos.
|
// honest typos.
|
||||||
let ip = client_ip(&headers, "unknown");
|
let ip = client_ip(&headers, "unknown");
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
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;
|
let admin_rate_on =
|
||||||
if rate_limits_on && admin_rate_on
|
config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
|
||||||
&& !state.rate_limiter.check(
|
if rate_limits_on
|
||||||
format!("admin_login:{ip}"),
|
&& admin_rate_on
|
||||||
5,
|
&& !state
|
||||||
Duration::from_secs(60),
|
.rate_limiter
|
||||||
)
|
.check(format!("admin_login:{ip}"), 5, Duration::from_secs(60))
|
||||||
{
|
{
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
"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)
|
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash).unwrap_or(false);
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !valid {
|
if !valid {
|
||||||
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
||||||
@@ -330,8 +329,8 @@ pub async fn admin_login(
|
|||||||
let dummy_pin: String = (0..32)
|
let dummy_pin: String = (0..32)
|
||||||
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
|
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
|
||||||
.collect();
|
.collect();
|
||||||
let dummy_hash = bcrypt::hash(&dummy_pin, 4)
|
let dummy_hash =
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
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?;
|
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
|
||||||
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
|
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
@@ -364,10 +363,7 @@ pub async fn admin_login(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout(
|
pub async fn logout(State(state): State<AppState>, auth: AuthUser) -> Result<StatusCode, AppError> {
|
||||||
State(state): State<AppState>,
|
|
||||||
auth: AuthUser,
|
|
||||||
) -> Result<StatusCode, AppError> {
|
|
||||||
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
|
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
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)
|
let user = Session::find_user_by_token_hash(&state.pool, &token_hash)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(e.into()))?
|
.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
|
// 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.
|
// an active client's session renews instead of hitting the fixed 30-day cliff.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::path::PathBuf;
|
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
|
/// Well-known dev JWT secret shipped in `.env.example`. If APP_ENV=production
|
||||||
/// we refuse to start with this value; otherwise we warn loudly.
|
/// we refuse to start with this value; otherwise we warn loudly.
|
||||||
@@ -67,8 +67,7 @@ pub struct AppConfig {
|
|||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
let app_env =
|
let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
|
||||||
std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
|
|
||||||
let is_prod = app_env.eq_ignore_ascii_case("production");
|
let is_prod = app_env.eq_ignore_ascii_case("production");
|
||||||
|
|
||||||
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
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)?;
|
validate_secrets(is_prod, &jwt_secret, &admin_password_hash)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
database_url: std::env::var("DATABASE_URL")
|
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?,
|
||||||
.context("DATABASE_URL must be set")?,
|
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
|
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
|
||||||
.unwrap_or_else(|_| "30".to_string())
|
.unwrap_or_else(|_| "30".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.context("SESSION_EXPIRY_DAYS must be a number")?,
|
.context("SESSION_EXPIRY_DAYS must be a number")?,
|
||||||
admin_password_hash,
|
admin_password_hash,
|
||||||
event_name: std::env::var("EVENT_NAME")
|
event_name: std::env::var("EVENT_NAME").unwrap_or_else(|_| "EventSnap".to_string()),
|
||||||
.unwrap_or_else(|_| "EventSnap".to_string()),
|
event_slug: std::env::var("EVENT_SLUG").context("EVENT_SLUG must be set")?,
|
||||||
event_slug: std::env::var("EVENT_SLUG")
|
|
||||||
.context("EVENT_SLUG must be set")?,
|
|
||||||
media_path: PathBuf::from(
|
media_path: PathBuf::from(
|
||||||
std::env::var("MEDIA_PATH").unwrap_or_else(|_| "/media".to_string()),
|
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 exact string shipped in `.env` — >32 chars, so it must be caught by
|
||||||
// the substring guard, not the length check.
|
// the substring guard, not the length check.
|
||||||
let err = validate_secrets(true, "change_me_to_a_random_64_byte_hex_string", REAL_HASH);
|
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]
|
#[test]
|
||||||
@@ -154,7 +153,9 @@ mod tests {
|
|||||||
fn placeholder_detection_is_case_insensitive() {
|
fn placeholder_detection_is_case_insensitive() {
|
||||||
// looks_placeholder lowercases before matching — an upper/mixed-case
|
// looks_placeholder lowercases before matching — an upper/mixed-case
|
||||||
// placeholder must still be rejected in prod.
|
// 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());
|
assert!(validate_secrets(true, REAL_SECRET, "$2Y$12$PLACEHOLDER_replace_me").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
|
||||||
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
|
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ pub enum AppError {
|
|||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
Unauthorized(String),
|
Unauthorized(String),
|
||||||
Forbidden(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),
|
NotFound(String),
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
/// Second field: optional retry-after seconds to include in the response.
|
/// 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::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
||||||
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||||
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
|
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
|
||||||
|
Self::UploadsLocked(_) => (StatusCode::FORBIDDEN, "uploads_locked"),
|
||||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
||||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||||
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||||
@@ -36,6 +42,7 @@ impl AppError {
|
|||||||
Self::BadRequest(msg)
|
Self::BadRequest(msg)
|
||||||
| Self::Unauthorized(msg)
|
| Self::Unauthorized(msg)
|
||||||
| Self::Forbidden(msg)
|
| Self::Forbidden(msg)
|
||||||
|
| Self::UploadsLocked(msg)
|
||||||
| Self::NotFound(msg)
|
| Self::NotFound(msg)
|
||||||
| Self::Conflict(msg) => msg.clone(),
|
| Self::Conflict(msg) => msg.clone(),
|
||||||
Self::TooManyRequests(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();
|
let mut resp = (status, axum::Json(body)).into_response();
|
||||||
if let Some(secs) = retry_after_secs {
|
if let Some(secs) = retry_after_secs
|
||||||
if let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string()) {
|
&& let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string())
|
||||||
resp.headers_mut().insert(axum::http::header::RETRY_AFTER, val);
|
{
|
||||||
}
|
resp.headers_mut()
|
||||||
|
.insert(axum::http::header::RETRY_AFTER, val);
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::Json;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::auth::middleware::RequireAdmin;
|
use crate::auth::middleware::RequireAdmin;
|
||||||
@@ -45,19 +45,17 @@ pub async fn get_stats(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||||
|
|
||||||
let (user_count,): (i64,) =
|
let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1")
|
||||||
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)
|
.bind(event.id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.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(
|
let (comment_count,): (i64,) = sqlx::query_as(
|
||||||
"SELECT COUNT(*) FROM comment c
|
"SELECT COUNT(*) FROM comment c
|
||||||
JOIN upload u ON u.id = c.upload_id
|
JOIN upload u ON u.id = c.upload_id
|
||||||
@@ -90,14 +88,17 @@ pub async fn get_config(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireAdmin(_auth): RequireAdmin,
|
RequireAdmin(_auth): RequireAdmin,
|
||||||
) -> Result<Json<HashMap<String, String>>, AppError> {
|
) -> Result<Json<HashMap<String, String>>, AppError> {
|
||||||
let rows: Vec<(String, String)> =
|
let rows: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM config ORDER BY key")
|
||||||
sqlx::query_as("SELECT key, value FROM config ORDER BY key")
|
.fetch_all(&state.pool)
|
||||||
.fetch_all(&state.pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(rows.into_iter().collect()))
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct PatchConfigRequest(pub HashMap<String, String>);
|
pub struct PatchConfigRequest(pub HashMap<String, String>);
|
||||||
|
|
||||||
@@ -128,6 +129,11 @@ pub async fn patch_config(
|
|||||||
"feed_rate_enabled",
|
"feed_rate_enabled",
|
||||||
"export_rate_enabled",
|
"export_rate_enabled",
|
||||||
"join_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",
|
"quota_enabled",
|
||||||
"storage_quota_enabled",
|
"storage_quota_enabled",
|
||||||
"upload_count_quota_enabled",
|
"upload_count_quota_enabled",
|
||||||
@@ -150,7 +156,7 @@ pub async fn patch_config(
|
|||||||
None => {
|
None => {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
"Ungültiger Wert für {key}: muss eine Zahl sein."
|
"Ungültiger Wert für {key}: muss eine Zahl sein."
|
||||||
)))
|
)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if integer_only && n.fract() != 0.0 {
|
if integer_only && n.fract() != 0.0 {
|
||||||
@@ -288,22 +294,50 @@ pub async fn download_zip(
|
|||||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
authenticate_download_ticket(&state, &q.ticket).await?;
|
||||||
enforce_export_rate(&state, &headers).await?;
|
enforce_export_rate(&state, &headers).await?;
|
||||||
|
|
||||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
let path =
|
||||||
.await?
|
resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
|
||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
serve_file(path, "Gallery.zip", "application/zip").await
|
||||||
|
}
|
||||||
|
|
||||||
if !event.export_zip_ready {
|
/// Resolve the on-disk path of the CURRENT export generation — readiness check and path lookup in
|
||||||
return Err(AppError::NotFound(
|
/// ONE read, through the `export_current` view (migration 014).
|
||||||
"Der ZIP-Export ist noch nicht verfügbar.".into(),
|
///
|
||||||
));
|
/// 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() {
|
if !path.exists() {
|
||||||
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
|
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
|
||||||
}
|
}
|
||||||
|
Ok(path)
|
||||||
serve_file(path, "Gallery.zip", "application/zip").await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_html(
|
pub async fn download_html(
|
||||||
@@ -314,21 +348,8 @@ pub async fn download_html(
|
|||||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
authenticate_download_ticket(&state, &q.ticket).await?;
|
||||||
enforce_export_rate(&state, &headers).await?;
|
enforce_export_rate(&state, &headers).await?;
|
||||||
|
|
||||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
let path =
|
||||||
.await?
|
resolve_export_file(&state, "html", "Der HTML-Export ist noch nicht verfügbar.").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()));
|
|
||||||
}
|
|
||||||
|
|
||||||
serve_file(path, "Memories.zip", "application/zip").await
|
serve_file(path, "Memories.zip", "application/zip").await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +359,7 @@ async fn serve_file(
|
|||||||
content_type: &str,
|
content_type: &str,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{header, Response, StatusCode};
|
use axum::http::{Response, StatusCode, header};
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
let file = tokio::fs::File::open(&path)
|
let file = tokio::fs::File::open(&path)
|
||||||
@@ -374,8 +395,19 @@ pub async fn export_status(
|
|||||||
|
|
||||||
let released = event.export_released_at.is_some();
|
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(
|
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)
|
.bind(event.id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
@@ -384,9 +416,7 @@ pub async fn export_status(
|
|||||||
let job_status = |type_name: &str| {
|
let job_status = |type_name: &str| {
|
||||||
jobs.iter()
|
jobs.iter()
|
||||||
.find(|(t, _, _)| t == type_name)
|
.find(|(t, _, _)| t == type_name)
|
||||||
.map(|(_, status, pct)| {
|
.map(|(_, status, pct)| serde_json::json!({ "status": status, "progress_pct": pct }))
|
||||||
serde_json::json!({ "status": status, "progress_pct": pct })
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| serde_json::json!({ "status": "locked", "progress_pct": 0 }))
|
.unwrap_or_else(|| serde_json::json!({ "status": "locked", "progress_pct": 0 }))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
use axum::Json;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -130,7 +130,11 @@ pub async fn feed(
|
|||||||
|
|
||||||
let has_more = rows.len() as i64 > limit;
|
let has_more = rows.len() as i64 > limit;
|
||||||
let rows: Vec<FeedRow> = rows.into_iter().take(limit as usize).collect();
|
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
|
// Batch check which uploads the current user has liked
|
||||||
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
|
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
|
||||||
@@ -181,6 +185,12 @@ pub struct DeltaQuery {
|
|||||||
pub struct DeltaResponse {
|
pub struct DeltaResponse {
|
||||||
pub uploads: Vec<FeedUpload>,
|
pub uploads: Vec<FeedUpload>,
|
||||||
pub deleted_ids: Vec<Uuid>,
|
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
|
/// 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
|
/// 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
|
/// 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
|
// 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.
|
// fall back to a full feed refresh rather than another delta.
|
||||||
const DELTA_LIMIT: i64 = 200;
|
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>(
|
let rows = sqlx::query_as::<_, FeedRow>(
|
||||||
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
|
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
|
||||||
mime_type, caption, like_count, comment_count, created_at
|
mime_type, caption, like_count, comment_count, created_at
|
||||||
FROM v_feed
|
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
|
ORDER BY created_at DESC, id DESC
|
||||||
LIMIT $3",
|
LIMIT $3",
|
||||||
)
|
)
|
||||||
@@ -247,9 +263,23 @@ pub async fn feed_delta(
|
|||||||
// client to full-refresh instead of merging a partial delta.
|
// client to full-refresh instead of merging a partial delta.
|
||||||
let truncated = rows.len() as i64 >= DELTA_LIMIT;
|
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(
|
let deleted_ids: Vec<(Uuid,)> = sqlx::query_as(
|
||||||
"SELECT id FROM upload
|
"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(auth.event_id)
|
||||||
.bind(q.since)
|
.bind(q.since)
|
||||||
@@ -285,6 +315,7 @@ pub async fn feed_delta(
|
|||||||
Ok(Json(DeltaResponse {
|
Ok(Json(DeltaResponse {
|
||||||
uploads,
|
uploads,
|
||||||
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
|
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,
|
truncated,
|
||||||
server_time,
|
server_time,
|
||||||
}))
|
}))
|
||||||
@@ -300,12 +331,11 @@ pub async fn hashtags(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
) -> Result<Json<Vec<HashtagCount>>, AppError> {
|
) -> Result<Json<Vec<HashtagCount>>, AppError> {
|
||||||
let rows: Vec<(String, i64)> = sqlx::query_as(
|
let rows: Vec<(String, i64)> =
|
||||||
"SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1",
|
sqlx::query_as("SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1")
|
||||||
)
|
.bind(auth.event_id)
|
||||||
.bind(auth.event_id)
|
.fetch_all(&state.pool)
|
||||||
.fetch_all(&state.pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
@@ -335,14 +365,13 @@ async fn get_liked_set(
|
|||||||
if upload_ids.is_empty() {
|
if upload_ids.is_empty() {
|
||||||
return std::collections::HashSet::new();
|
return std::collections::HashSet::new();
|
||||||
}
|
}
|
||||||
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
let rows: Vec<(Uuid,)> =
|
||||||
"SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)",
|
sqlx::query_as("SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)")
|
||||||
)
|
.bind(user_id)
|
||||||
.bind(user_id)
|
.bind(upload_ids)
|
||||||
.bind(upload_ids)
|
.fetch_all(pool)
|
||||||
.fetch_all(pool)
|
.await
|
||||||
.await
|
.unwrap_or_default();
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
rows.into_iter().map(|r| r.0).collect()
|
rows.into_iter().map(|r| r.0).collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
use axum::Json;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::Json;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -12,6 +12,7 @@ use crate::models::event::Event;
|
|||||||
use crate::models::session::Session;
|
use crate::models::session::Session;
|
||||||
use crate::models::upload::Upload;
|
use crate::models::upload::Upload;
|
||||||
use crate::models::user::UserRole;
|
use crate::models::user::UserRole;
|
||||||
|
use crate::services::export::Affects;
|
||||||
use crate::state::{AppState, SseEvent};
|
use crate::state::{AppState, SseEvent};
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -56,7 +57,6 @@ async fn remaining_operators(
|
|||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct SetRoleRequest {
|
pub struct SetRoleRequest {
|
||||||
pub role: String,
|
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).
|
// The ban request carries no body — ban always hides (no per-request options).
|
||||||
// Cannot ban yourself or another host/admin
|
// Cannot ban yourself or another host/admin
|
||||||
if user_id == auth.user_id {
|
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,)>(
|
let target = sqlx::query_as::<_, (String,)>(
|
||||||
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
||||||
@@ -125,8 +127,12 @@ pub async fn ban_user(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||||
|
|
||||||
if target.0 == "admin" || (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin) {
|
if target.0 == "admin"
|
||||||
return Err(AppError::Forbidden("Du kannst diesen Benutzer nicht sperren.".into()));
|
|| (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
|
// 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
|
// (enforced live on the write handlers + Require{Host,Admin}). Revoking sessions would
|
||||||
// contradict that model, break the documented "banned guest can still download the
|
// 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).
|
// 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(
|
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(user_id)
|
||||||
.bind(auth.event_id)
|
.bind(auth.event_id)
|
||||||
.execute(&state.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.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
|
// 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
|
// 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
|
// 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
|
// 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(
|
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(user_id)
|
||||||
.bind(auth.event_id)
|
.bind(auth.event_id)
|
||||||
.execute(&state.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(AppError::NotFound("Benutzer nicht gefunden.".into()));
|
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!(
|
tracing::info!(
|
||||||
actor_user_id = %auth.user_id,
|
actor_user_id = %auth.user_id,
|
||||||
target_user_id = %user_id,
|
target_user_id = %user_id,
|
||||||
@@ -221,6 +265,54 @@ pub async fn unban_user(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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(
|
pub async fn set_role(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(auth): RequireHost,
|
RequireHost(auth): RequireHost,
|
||||||
@@ -238,14 +330,15 @@ pub async fn set_role(
|
|||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"Ungültige Rolle. Erlaubt: guest, host.".into(),
|
"Ungültige Rolle. Erlaubt: guest, host.".into(),
|
||||||
))
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Look up the current role so we can apply the host-vs-admin guard. Hosts may
|
// Look up the current role so we can apply the host-vs-admin guard. A plain host may
|
||||||
// promote guests and demote *other* hosts (the user explicitly requested this
|
// promote/demote GUESTS only; it may not change any host's or admin's role (see the
|
||||||
// expansion). Hosts may not touch admins. Admins may do anything (except change
|
// guard below — this closes the demote-a-peer-host→ban/PIN-reset takeover chain, F1).
|
||||||
// themselves, blocked above).
|
// Only an admin may change a host's role. Admins may do anything except change
|
||||||
|
// themselves (blocked above).
|
||||||
let target = sqlx::query_as::<_, (String,)>(
|
let target = sqlx::query_as::<_, (String,)>(
|
||||||
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
"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(
|
return Err(AppError::Forbidden(
|
||||||
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
|
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
|
||||||
))
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
||||||
let pin_hash =
|
let pin_hash = bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||||
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE \"user\"
|
"UPDATE \"user\"
|
||||||
@@ -356,8 +448,13 @@ pub async fn reset_user_pin(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// A PIN reset means the old credential is compromised/forgotten — revoke every
|
// A PIN reset means the old credential is compromised/forgotten — revoke every
|
||||||
// existing session so old devices must re-authenticate with the new PIN.
|
// existing session so old devices must re-authenticate with the new PIN. This is a
|
||||||
let _ = Session::delete_all_for_user(&state.pool, user_id).await;
|
// 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.
|
// 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")
|
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)
|
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(
|
pub async fn host_delete_upload(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(auth): RequireHost,
|
RequireHost(auth): RequireHost,
|
||||||
@@ -433,15 +569,29 @@ pub async fn host_delete_upload(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
.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 {
|
if !deleted {
|
||||||
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
|
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(
|
let _ = state.sse_tx.send(SseEvent::new(
|
||||||
"upload-deleted",
|
"upload-deleted",
|
||||||
serde_json::json!({ "upload_id": upload.id }).to_string(),
|
serde_json::json!({ "upload_id": upload.id }).to_string(),
|
||||||
));
|
));
|
||||||
|
if let Some(r) = regen {
|
||||||
|
start_regen(&state, r);
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
actor_user_id = %auth.user_id,
|
actor_user_id = %auth.user_id,
|
||||||
@@ -458,15 +608,29 @@ pub async fn host_delete_comment(
|
|||||||
RequireHost(auth): RequireHost,
|
RequireHost(auth): RequireHost,
|
||||||
Path(comment_id): Path<Uuid>,
|
Path(comment_id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
let deleted =
|
let mut tx = state.pool.begin().await?;
|
||||||
Comment::soft_delete_in_event(&state.pool, comment_id, auth.event_id).await?;
|
let deleted = Comment::soft_delete_in_event(&mut tx, comment_id, auth.event_id).await?;
|
||||||
if !deleted {
|
if !deleted {
|
||||||
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
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(
|
let _ = state.sse_tx.send(SseEvent::new(
|
||||||
"comment-deleted",
|
"comment-deleted",
|
||||||
serde_json::json!({ "comment_id": comment_id }).to_string(),
|
serde_json::json!({ "comment_id": comment_id }).to_string(),
|
||||||
));
|
));
|
||||||
|
if let Some(r) = regen {
|
||||||
|
start_regen(&state, r);
|
||||||
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
actor_user_id = %auth.user_id,
|
actor_user_id = %auth.user_id,
|
||||||
event_id = %auth.event_id,
|
event_id = %auth.event_id,
|
||||||
@@ -500,16 +664,20 @@ pub async fn open_event(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(_auth): RequireHost,
|
RequireHost(_auth): RequireHost,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
// Reopening also invalidates any prior release: the keepsake was snapshotted at
|
// Reopening invalidates any prior release: the keepsake was snapshotted at release time, so
|
||||||
// release time, so allowing new uploads afterwards would silently diverge the live
|
// allowing new uploads afterwards would silently diverge the live feed from the frozen export.
|
||||||
// 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.
|
// 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(
|
let result = sqlx::query(
|
||||||
"UPDATE event
|
"UPDATE event
|
||||||
SET uploads_locked_at = NULL,
|
SET uploads_locked_at = NULL,
|
||||||
export_released_at = NULL,
|
export_released_at = NULL,
|
||||||
export_zip_ready = FALSE,
|
export_epoch = export_epoch + 1
|
||||||
export_html_ready = FALSE
|
|
||||||
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
|
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
|
||||||
)
|
)
|
||||||
.bind(&state.config.event_slug)
|
.bind(&state.config.event_slug)
|
||||||
@@ -527,24 +695,37 @@ pub async fn release_gallery(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(_auth): RequireHost,
|
RequireHost(_auth): RequireHost,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
// Atomic claim: the conditional UPDATE is the sole gate, so two concurrent
|
// The release claim, the epoch bump, the upload lock and BOTH job rows are written in ONE
|
||||||
// release calls can't both pass a check-then-set and double-enqueue exports.
|
// transaction. Two reasons, both of which were live bugs:
|
||||||
// rows_affected == 0 means someone already released.
|
|
||||||
//
|
//
|
||||||
// Releasing also locks uploads in the same statement (release ⇒ lock). Otherwise a
|
// 1. Cancellation. This handler used to commit `export_released_at` and only THEN await the
|
||||||
// guest whose offline upload reconnects *after* the export snapshot would land in the
|
// enqueue. Axum drops the handler future when the client disconnects (closed tab, proxy
|
||||||
// live feed but not in the downloaded keepsake — a silent, non-regenerable data loss.
|
// timeout), which left the event released and uploads locked with ZERO export_job rows and
|
||||||
// `COALESCE` preserves an earlier explicit lock time rather than overwriting it.
|
// no workers: downloads 404 forever and the host cannot retry, because release_gallery
|
||||||
let result = sqlx::query(
|
// 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
|
"UPDATE event
|
||||||
SET export_released_at = NOW(),
|
SET export_released_at = NOW(),
|
||||||
uploads_locked_at = COALESCE(uploads_locked_at, NOW())
|
uploads_locked_at = COALESCE(uploads_locked_at, NOW()),
|
||||||
WHERE slug = $1 AND export_released_at IS NULL",
|
export_epoch = export_epoch + 1
|
||||||
|
WHERE slug = $1 AND export_released_at IS NULL
|
||||||
|
RETURNING id, name, export_epoch",
|
||||||
)
|
)
|
||||||
.bind(&state.config.event_slug)
|
.bind(&state.config.event_slug)
|
||||||
.execute(&state.pool)
|
.fetch_optional(&mut *tx)
|
||||||
.await?;
|
.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.
|
// Distinguish "no such event" from "already released" for a clean error.
|
||||||
let exists = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
let exists = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||||
.await?
|
.await?
|
||||||
@@ -554,28 +735,30 @@ pub async fn release_gallery(
|
|||||||
} else {
|
} else {
|
||||||
AppError::NotFound("Event nicht gefunden.".into())
|
AppError::NotFound("Event nicht gefunden.".into())
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
// Release locked uploads too — tell any open composer to flip to the locked UI live
|
// Arm both types at THIS epoch. No "skip the type that's already ready" check any more: the
|
||||||
// rather than discovering it via a rejected upload.
|
// 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", "{}"));
|
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||||
|
|
||||||
// We won the claim — load the event for its id/name to enqueue export jobs.
|
// Detached — survives this handler being cancelled.
|
||||||
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
crate::services::export::spawn_export_jobs(
|
||||||
.await?
|
event_id,
|
||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
event_name,
|
||||||
|
epoch,
|
||||||
// Enqueue + spawn via the shared path so a re-release (after reopen) regenerates
|
std::time::Duration::ZERO,
|
||||||
// cleanly and startup recovery uses identical logic.
|
|
||||||
crate::services::export::enqueue_and_spawn_exports(
|
|
||||||
event.id,
|
|
||||||
event.name,
|
|
||||||
state.pool.clone(),
|
state.pool.clone(),
|
||||||
state.config.media_path.clone(),
|
state.config.media_path.clone(),
|
||||||
state.config.export_path.clone(),
|
state.config.export_path.clone(),
|
||||||
state.sse_tx.clone(),
|
state.sse_tx.clone(),
|
||||||
)
|
);
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
//! account page loads this once on mount instead of issuing several round trips.
|
//! account page loads this once on mount instead of issuing several round trips.
|
||||||
//! - `GET /api/v1/me/quota` — live per-user storage quota estimate.
|
//! - `GET /api/v1/me/quota` — live per-user storage quota estimate.
|
||||||
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::auth::middleware::AuthUser;
|
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 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 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)
|
let event =
|
||||||
.await?;
|
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 uploads_locked = event
|
||||||
let gallery_released = event.as_ref().map(|e| e.export_released_at.is_some()).unwrap_or(false);
|
.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 {
|
Ok(Json(MeContextDto {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Unauthenticated, read-only endpoints safe to expose before a user has joined.
|
//! Unauthenticated, read-only endpoints safe to expose before a user has joined.
|
||||||
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
use axum::Json;
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::Json;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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
|
// 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.
|
// 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 {
|
if !deleted {
|
||||||
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
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(
|
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||||
"comment-deleted",
|
"comment-deleted",
|
||||||
serde_json::json!({ "comment_id": comment_id, "upload_id": comment.upload_id }).to_string(),
|
serde_json::json!({ "comment_id": comment_id, "upload_id": comment.upload_id }).to_string(),
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use axum::Json;
|
|
||||||
use futures::stream::Stream;
|
use futures::stream::Stream;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
|
||||||
use tokio_stream::wrappers::BroadcastStream;
|
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
|
use tokio_stream::wrappers::BroadcastStream;
|
||||||
|
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||||
|
|
||||||
use crate::auth::middleware::AuthUser;
|
use crate::auth::middleware::AuthUser;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -42,7 +42,10 @@ pub async fn issue_ticket(
|
|||||||
let server_time = sqlx::query_scalar("SELECT NOW()")
|
let server_time = sqlx::query_scalar("SELECT NOW()")
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(StreamTicketResponse { ticket, server_time }))
|
Ok(Json(StreamTicketResponse {
|
||||||
|
ticket,
|
||||||
|
server_time,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SSE stream endpoint. Authenticates via a single-use ticket (see
|
/// 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
|
// 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
|
// 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
|
// until the client happens to disconnect. Bounds a stale stream to ~60s. NOTE: a ban is
|
||||||
// already revokes the user's sessions (so the row is gone), but re-reading the live
|
// deliberately read-only and does NOT revoke sessions (see `ban_user`), so the
|
||||||
// user row and dropping on `is_banned` is a cheap defense-in-depth. Only a
|
// `is_banned` re-read below is LOAD-BEARING — it is the only thing that stops a banned
|
||||||
// *definitive* gone/expired/banned state ends the stream; a transient DB error just
|
// user's live push stream. Do not remove it. Only a *definitive* gone/expired/banned
|
||||||
// retries next tick.
|
// state ends the stream; a transient DB error just retries next tick.
|
||||||
let pool = state.pool.clone();
|
let pool = state.pool.clone();
|
||||||
let session_hash = token_hash.clone();
|
let session_hash = token_hash.clone();
|
||||||
let session_gone = async move {
|
let session_gone = async move {
|
||||||
|
|||||||
@@ -85,6 +85,26 @@ pub async fn truncate_all(
|
|||||||
// could serve the previous test's toggles.
|
// could serve the previous test's toggles.
|
||||||
state.config_cache.invalidate();
|
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)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
use axum::extract::{Multipart, Path, State};
|
use axum::extract::{Multipart, Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::Json;
|
use chrono::{DateTime, Utc};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
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 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;
|
let upload_rate_on = config::get_bool(&state.config_cache, "upload_rate_enabled", true).await;
|
||||||
if rate_limits_on && upload_rate_on {
|
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(
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
format!("upload:{}", auth.user_id),
|
format!("upload:{}", auth.user_id),
|
||||||
upload_rate,
|
upload_rate,
|
||||||
@@ -75,14 +77,19 @@ pub async fn upload(
|
|||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||||
if event.uploads_locked_at.is_some() {
|
if event.uploads_locked_at.is_some() {
|
||||||
drain_multipart(multipart).await;
|
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
|
// 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
|
// 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.
|
// 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() {
|
if event.export_released_at.is_some() {
|
||||||
drain_multipart(multipart).await;
|
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
|
// 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.
|
// On success the temp file is renamed into place under its detected extension.
|
||||||
let upload_id = Uuid::new_v4();
|
let upload_id = Uuid::new_v4();
|
||||||
let event_slug = &state.config.event_slug;
|
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 temp_abs = originals_dir.join(format!("{upload_id}.tmp"));
|
||||||
|
|
||||||
let mut streamed: Option<(i64, Vec<u8>)> = None; // (size, head bytes for sniffing)
|
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?);
|
streamed = Some(stream_field_to_file(field, &temp_abs, cap_bytes).await?);
|
||||||
}
|
}
|
||||||
"caption" => {
|
"caption" => {
|
||||||
caption =
|
caption = Some(
|
||||||
Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
|
field
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::BadRequest(e.to_string()))?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
"hashtags" => {
|
"hashtags" => {
|
||||||
hashtags_csv =
|
hashtags_csv = Some(
|
||||||
Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
|
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
|
// Validate caption length. Counted in chars (code points) to match the
|
||||||
// "Zeichen" wording in the error message — `.len()` would be bytes and
|
// "Zeichen" wording in the error message — `.len()` would be bytes and
|
||||||
// reject perfectly valid German/emoji captions early.
|
// reject perfectly valid German/emoji captions early.
|
||||||
if let Some(ref cap) = caption {
|
if let Some(ref cap) = caption
|
||||||
if cap.chars().count() > MAX_CAPTION_LENGTH {
|
&& cap.chars().count() > MAX_CAPTION_LENGTH
|
||||||
let _ = tokio::fs::remove_file(&temp_abs).await;
|
{
|
||||||
return Err(AppError::BadRequest(format!(
|
let _ = tokio::fs::remove_file(&temp_abs).await;
|
||||||
"Beschreibung ist zu lang. Maximum: {} Zeichen.",
|
return Err(AppError::BadRequest(format!(
|
||||||
MAX_CAPTION_LENGTH
|
"Beschreibung ist zu lang. Maximum: {} Zeichen.",
|
||||||
)));
|
MAX_CAPTION_LENGTH
|
||||||
}
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the file type from its magic bytes and require it to be on the
|
// 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
|
// number of active uploaders. Gated by master + per-area toggles so the admin can
|
||||||
// disable it on trusted instances.
|
// disable it on trusted instances.
|
||||||
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
|
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
|
// 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
|
// 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
|
// 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).
|
// bytes with no row to reclaim them (silent quota erosion / spurious lockout).
|
||||||
let tx_result: Result<Upload, AppError> = async {
|
let tx_result: Result<Upload, AppError> = async {
|
||||||
let mut tx = state.pool.begin().await?;
|
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
|
// 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
|
// (`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
|
// 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)
|
.execute(&mut *tx)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
|
sqlx::query(
|
||||||
.bind(auth.user_id)
|
"UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1",
|
||||||
.bind(size)
|
)
|
||||||
.execute(&mut *tx)
|
.bind(auth.user_id)
|
||||||
.await?
|
.bind(size)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
if inc.rows_affected() == 0 {
|
if inc.rows_affected() == 0 {
|
||||||
return Err(AppError::QuotaExceeded(
|
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
|
// Caption update + hashtag wipe-then-relink in one transaction, so a crash
|
||||||
// mid-relink can't leave the upload with its hashtags stripped.
|
// 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?;
|
let mut tx = state.pool.begin().await?;
|
||||||
if let Some(ref caption) = body.caption {
|
if let Some(ref caption) = body.caption {
|
||||||
Upload::update_caption(&mut *tx, upload_id, Some(caption)).await?;
|
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?;
|
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?;
|
tx.commit().await?;
|
||||||
|
if let Some(r) = regen {
|
||||||
|
crate::handlers::host::start_regen(&state, r);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
@@ -413,7 +482,20 @@ pub async fn delete_upload(
|
|||||||
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
|
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
|
// Evict the card live on every other feed + the projector diashow — otherwise
|
||||||
// a self-deleted post lingers until each viewer manually reloads. Same event
|
// 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 limit_bytes: Option<i64>,
|
||||||
pub active_uploaders: i64,
|
pub active_uploaders: i64,
|
||||||
pub free_disk_bytes: 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,
|
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).
|
/// check (upload handler) or hide the UI (quota endpoint).
|
||||||
pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
||||||
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
|
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 tolerance = config::get_f64(&state.config_cache, "quota_tolerance", 0.75).await;
|
||||||
|
|
||||||
let (active_count,): (i64,) = sqlx::query_as(
|
let (active_count,): (i64,) =
|
||||||
"SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL",
|
sqlx::query_as("SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL")
|
||||||
)
|
.fetch_one(&state.pool)
|
||||||
.fetch_one(&state.pool)
|
.await
|
||||||
.await
|
.unwrap_or((0,));
|
||||||
.unwrap_or((0,));
|
|
||||||
let active = active_count.max(1);
|
let active = active_count.max(1);
|
||||||
|
|
||||||
// Cached disk reading. `None` means we couldn't resolve the media filesystem.
|
// Cached disk reading. `None` means we couldn't resolve the media filesystem.
|
||||||
@@ -574,7 +659,7 @@ async fn stream_media_file(
|
|||||||
cache_control: &str,
|
cache_control: &str,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{header, Response, StatusCode};
|
use axum::http::{Response, StatusCode, header};
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
if !absolute.exists() {
|
if !absolute.exists() {
|
||||||
@@ -653,7 +738,13 @@ pub async fn get_preview(
|
|||||||
.preview_path
|
.preview_path
|
||||||
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?;
|
||||||
let absolute = state.config.media_path.join(&rel);
|
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
|
/// Streaming access to an upload's **thumbnail** (video poster). Gated identically to
|
||||||
@@ -669,7 +760,13 @@ pub async fn get_thumbnail(
|
|||||||
.thumbnail_path
|
.thumbnail_path
|
||||||
.ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?;
|
||||||
let absolute = state.config.media_path.join(&rel);
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use axum::Router;
|
||||||
use axum::extract::DefaultBodyLimit;
|
use axum::extract::DefaultBodyLimit;
|
||||||
use axum::routing::{delete, get, patch, post};
|
use axum::routing::{delete, get, patch, post};
|
||||||
use axum::Router;
|
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
@@ -27,9 +27,10 @@ async fn main() -> Result<()> {
|
|||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
.with(
|
||||||
"eventsnap_backend=debug,tower_http=debug".into()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
}))
|
.unwrap_or_else(|_| "eventsnap_backend=debug,tower_http=debug".into()),
|
||||||
|
)
|
||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
@@ -73,7 +74,10 @@ async fn main() -> Result<()> {
|
|||||||
.route("/api/v1/join", post(auth::handlers::join))
|
.route("/api/v1/join", post(auth::handlers::join))
|
||||||
.route("/api/v1/recover", post(auth::handlers::recover))
|
.route("/api/v1/recover", post(auth::handlers::recover))
|
||||||
// Forgotten-PIN escape hatch: ask a host to reset it (unauthenticated, throttled).
|
// 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/admin/login", post(auth::handlers::admin_login))
|
||||||
.route("/api/v1/session", delete(auth::handlers::logout))
|
.route("/api/v1/session", delete(auth::handlers::logout))
|
||||||
// "Sign out everywhere" — revoke all of the caller's sessions.
|
// "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
|
// 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
|
// 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.
|
// overhead — if an admin raises max_video_size_mb above this, bump MAX_UPLOAD_BYTES.
|
||||||
.route("/api/v1/upload", post(handlers::upload::upload)
|
.route(
|
||||||
.route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)))
|
"/api/v1/upload",
|
||||||
|
post(handlers::upload::upload).route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/upload/{id}",
|
"/api/v1/upload/{id}",
|
||||||
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
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/feed/delta", get(handlers::feed::feed_delta))
|
||||||
.route("/api/v1/hashtags", get(handlers::feed::hashtags))
|
.route("/api/v1/hashtags", get(handlers::feed::hashtags))
|
||||||
// Social
|
// Social
|
||||||
.route("/api/v1/upload/{id}/like", post(handlers::social::toggle_like))
|
.route(
|
||||||
|
"/api/v1/upload/{id}/like",
|
||||||
|
post(handlers::social::toggle_like),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/upload/{id}/comments",
|
"/api/v1/upload/{id}/comments",
|
||||||
get(handlers::social::list_comments).post(handlers::social::add_comment),
|
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
|
// SSE
|
||||||
.route("/api/v1/stream", get(handlers::sse::stream))
|
.route("/api/v1/stream", get(handlers::sse::stream))
|
||||||
.route("/api/v1/stream/ticket", post(handlers::sse::issue_ticket))
|
.route("/api/v1/stream/ticket", post(handlers::sse::issue_ticket))
|
||||||
// Host Dashboard
|
// Host Dashboard
|
||||||
.route("/api/v1/host/event", get(handlers::host::get_event_status))
|
.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/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", get(handlers::host::list_users))
|
||||||
.route("/api/v1/host/users/{id}/ban", post(handlers::host::ban_user))
|
.route(
|
||||||
.route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user))
|
"/api/v1/host/users/{id}/ban",
|
||||||
.route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role))
|
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(
|
.route(
|
||||||
"/api/v1/host/users/{id}/pin-reset",
|
"/api/v1/host/users/{id}/pin-reset",
|
||||||
post(handlers::host::reset_user_pin),
|
post(handlers::host::reset_user_pin),
|
||||||
@@ -142,11 +175,20 @@ async fn main() -> Result<()> {
|
|||||||
"/api/v1/host/pin-reset-requests/{id}",
|
"/api/v1/host/pin-reset-requests/{id}",
|
||||||
delete(handlers::host::dismiss_pin_reset_request),
|
delete(handlers::host::dismiss_pin_reset_request),
|
||||||
)
|
)
|
||||||
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload))
|
.route(
|
||||||
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
|
"/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)
|
// Export (all authenticated users)
|
||||||
.route("/api/v1/export/status", get(handlers::admin::export_status))
|
.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/zip", get(handlers::admin::download_zip))
|
||||||
.route("/api/v1/export/html", get(handlers::admin::download_html))
|
.route("/api/v1/export/html", get(handlers::admin::download_html))
|
||||||
// Admin Dashboard
|
// Admin Dashboard
|
||||||
@@ -155,7 +197,10 @@ async fn main() -> Result<()> {
|
|||||||
"/api/v1/admin/config",
|
"/api/v1/admin/config",
|
||||||
get(handlers::admin::get_config).patch(handlers::admin::patch_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
|
// Test-only route: a hard reset for the Playwright E2E harness. The handler
|
||||||
// is compiled in always, but the route is only attached when
|
// is compiled in always, but the route is only attached when
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ use serde::Serialize;
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct Comment {
|
pub struct Comment {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -79,26 +83,18 @@ impl Comment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Self>(
|
sqlx::query_as::<_, Self>("SELECT * FROM comment WHERE id = $1 AND deleted_at IS NULL")
|
||||||
"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")
|
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if the
|
/// Event-scoped soft delete. Returns `false` if the comment doesn't exist or belongs to a
|
||||||
/// comment doesn't exist or belongs to a different event.
|
/// 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(
|
pub async fn soft_delete_in_event(
|
||||||
pool: &PgPool,
|
conn: &mut sqlx::PgConnection,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
) -> Result<bool, sqlx::Error> {
|
) -> Result<bool, sqlx::Error> {
|
||||||
@@ -111,7 +107,7 @@ impl Comment {
|
|||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(event_id)
|
.bind(event_id)
|
||||||
.execute(pool)
|
.execute(conn)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(result.rows_affected() > 0)
|
Ok(result.rows_affected() > 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ use chrono::{DateTime, Utc};
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct Event {
|
pub struct Event {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -11,8 +16,11 @@ pub struct Event {
|
|||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
pub uploads_locked_at: Option<DateTime<Utc>>,
|
pub uploads_locked_at: Option<DateTime<Utc>>,
|
||||||
pub export_released_at: Option<DateTime<Utc>>,
|
pub export_released_at: Option<DateTime<Utc>>,
|
||||||
pub export_zip_ready: bool,
|
/// Monotonic generation counter for the keepsake. Bumped in the SAME UPDATE as any change to
|
||||||
pub export_html_ready: bool,
|
/// `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>,
|
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> {
|
pub async fn create(pool: &PgPool, slug: &str, name: &str) -> Result<Self, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Self>(
|
sqlx::query_as::<_, Self>("INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *")
|
||||||
"INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *",
|
.bind(slug)
|
||||||
)
|
.bind(name)
|
||||||
.bind(slug)
|
.fetch_one(pool)
|
||||||
.bind(name)
|
.await
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_or_create(
|
pub async fn find_or_create(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct Hashtag {
|
pub struct Hashtag {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -61,22 +63,6 @@ impl Hashtag {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
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
|
/// 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()]);
|
assert_eq!(extract_hashtags(&format!("#{ok}")), vec![ok.clone()]);
|
||||||
// 41+ chars → dropped entirely (not truncated).
|
// 41+ chars → dropped entirely (not truncated).
|
||||||
let too_long = "a".repeat(41);
|
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]
|
#[test]
|
||||||
fn duplicate_tags_are_returned_verbatim_not_deduplicated() {
|
fn duplicate_tags_are_returned_verbatim_not_deduplicated() {
|
||||||
// Dedup is the DB's job (Hashtag::upsert ON CONFLICT); extraction returns each
|
// 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.
|
// 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]
|
#[test]
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ use chrono::{DateTime, Utc};
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -87,10 +90,7 @@ impl Session {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_by_token_hash(
|
pub async fn delete_by_token_hash(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> {
|
||||||
pool: &PgPool,
|
|
||||||
token_hash: &str,
|
|
||||||
) -> Result<(), sqlx::Error> {
|
|
||||||
sqlx::query("DELETE FROM session WHERE token_hash = $1")
|
sqlx::query("DELETE FROM session WHERE token_hash = $1")
|
||||||
.bind(token_hash)
|
.bind(token_hash)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ use serde::Serialize;
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct Upload {
|
pub struct Upload {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -75,15 +79,6 @@ impl Upload {
|
|||||||
.await
|
.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`/
|
/// Lean lookup for the public media aliases (`get_original`/`get_preview`/
|
||||||
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
|
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
|
||||||
/// excluding soft-deleted rows, hidden owners (`uploads_hidden`), and banned owners
|
/// 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
|
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if no row
|
||||||
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
||||||
/// can return a clean 404 instead of silently no-op'ing.
|
/// 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(
|
pub async fn soft_delete_in_event(
|
||||||
pool: &PgPool,
|
conn: &mut sqlx::PgConnection,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
) -> Result<bool, sqlx::Error> {
|
) -> Result<bool, sqlx::Error> {
|
||||||
let mut tx = pool.begin().await?;
|
let tx = conn;
|
||||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
||||||
"UPDATE upload
|
"UPDATE upload
|
||||||
SET deleted_at = NOW()
|
SET deleted_at = NOW()
|
||||||
@@ -218,7 +218,6 @@ impl Upload {
|
|||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
tx.commit().await?;
|
|
||||||
Ok(deleted)
|
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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -105,14 +109,16 @@ impl User {
|
|||||||
Ok(row.0)
|
Ok(row.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn lock_pin(pool: &PgPool, id: Uuid, until: DateTime<Utc>) -> Result<(), sqlx::Error> {
|
pub async fn lock_pin(
|
||||||
sqlx::query(
|
pool: &PgPool,
|
||||||
"UPDATE \"user\" SET pin_locked_until = $2 WHERE id = $1",
|
id: Uuid,
|
||||||
)
|
until: DateTime<Utc>,
|
||||||
.bind(id)
|
) -> Result<(), sqlx::Error> {
|
||||||
.bind(until)
|
sqlx::query("UPDATE \"user\" SET pin_locked_until = $2 WHERE id = $1")
|
||||||
.execute(pool)
|
.bind(id)
|
||||||
.await?;
|
.bind(until)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tokio::sync::{broadcast, Semaphore};
|
use tokio::sync::{Semaphore, broadcast};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::upload::Upload;
|
use crate::models::upload::Upload;
|
||||||
@@ -15,24 +16,54 @@ pub struct CompressionWorker {
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
media_path: PathBuf,
|
media_path: PathBuf,
|
||||||
sse_tx: broadcast::Sender<SseEvent>,
|
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 {
|
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 {
|
Self {
|
||||||
semaphore: Arc::new(Semaphore::new(concurrency)),
|
semaphore: Arc::new(Semaphore::new(concurrency)),
|
||||||
pool,
|
pool,
|
||||||
media_path,
|
media_path,
|
||||||
sse_tx,
|
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.
|
/// Spawn a background task to process an uploaded file.
|
||||||
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
|
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
|
||||||
let worker = self.clone();
|
let worker = self.clone();
|
||||||
|
let born_at = worker.generation.load(Ordering::SeqCst);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _permit = worker.semaphore.acquire().await;
|
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(_) => {
|
Ok(_) => {
|
||||||
tracing::info!("compression completed for upload {upload_id}");
|
tracing::info!("compression completed for upload {upload_id}");
|
||||||
let _ = worker.sse_tx.send(SseEvent {
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
@@ -58,7 +89,8 @@ impl CompressionWorker {
|
|||||||
}
|
}
|
||||||
let _ = worker.sse_tx.send(SseEvent {
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
event_type: "upload-error".to_string(),
|
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 {
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
event_type: "upload-deleted".to_string(),
|
event_type: "upload-deleted".to_string(),
|
||||||
@@ -80,7 +112,9 @@ impl CompressionWorker {
|
|||||||
let original = self.media_path.join(original_path);
|
let original = self.media_path.join(original_path);
|
||||||
|
|
||||||
if mime_type.starts_with("image/") {
|
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?;
|
Upload::set_preview_path(&self.pool, upload_id, &preview_rel).await?;
|
||||||
tracing::info!("preview generated for upload {upload_id}");
|
tracing::info!("preview generated for upload {upload_id}");
|
||||||
} else if mime_type.starts_with("video/") {
|
} else if mime_type.starts_with("video/") {
|
||||||
@@ -128,7 +162,8 @@ impl CompressionWorker {
|
|||||||
|
|
||||||
// Resize to max 800px wide, preserving aspect ratio
|
// Resize to max 800px wide, preserving aspect ratio
|
||||||
let preview = img.resize(800, 800, image::imageops::FilterType::Lanczos3);
|
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")?;
|
.context("failed to save preview")?;
|
||||||
|
|
||||||
// If the original is PNG, try lossless compression in-place
|
// If the original is PNG, try lossless compression in-place
|
||||||
@@ -151,11 +186,7 @@ impl CompressionWorker {
|
|||||||
Ok(format!("previews/{preview_filename}"))
|
Ok(format!("previews/{preview_filename}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn generate_video_thumbnail(
|
async fn generate_video_thumbnail(&self, upload_id: Uuid, original: &Path) -> Result<String> {
|
||||||
&self,
|
|
||||||
upload_id: Uuid,
|
|
||||||
original: &Path,
|
|
||||||
) -> Result<String> {
|
|
||||||
let thumbs_dir = self.media_path.join("thumbnails");
|
let thumbs_dir = self.media_path.join("thumbnails");
|
||||||
tokio::fs::create_dir_all(&thumbs_dir).await?;
|
tokio::fs::create_dir_all(&thumbs_dir).await?;
|
||||||
|
|
||||||
@@ -185,18 +216,14 @@ impl CompressionWorker {
|
|||||||
.spawn()
|
.spawn()
|
||||||
.context("failed to spawn ffmpeg")?;
|
.context("failed to spawn ffmpeg")?;
|
||||||
|
|
||||||
let status = match tokio::time::timeout(
|
let status =
|
||||||
std::time::Duration::from_secs(120),
|
match tokio::time::timeout(std::time::Duration::from_secs(120), child.wait()).await {
|
||||||
child.wait(),
|
Ok(res) => res.context("ffmpeg wait failed")?,
|
||||||
)
|
Err(_) => {
|
||||||
.await
|
let _ = child.kill().await;
|
||||||
{
|
anyhow::bail!("ffmpeg timeout after 120s");
|
||||||
Ok(res) => res.context("ffmpeg wait failed")?,
|
}
|
||||||
Err(_) => {
|
};
|
||||||
let _ = child.kill().await;
|
|
||||||
anyhow::bail!("ffmpeg timeout after 120s");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
// Best-effort: drain stderr for the log.
|
// Best-effort: drain stderr for the log.
|
||||||
@@ -205,10 +232,7 @@ impl CompressionWorker {
|
|||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
let _ = handle.read_to_end(&mut stderr).await;
|
let _ = handle.read_to_end(&mut stderr).await;
|
||||||
}
|
}
|
||||||
anyhow::bail!(
|
anyhow::bail!("ffmpeg failed: {}", String::from_utf8_lossy(&stderr));
|
||||||
"ffmpeg failed: {}",
|
|
||||||
String::from_utf8_lossy(&stderr)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(format!("thumbnails/{thumb_filename}"))
|
Ok(format!("thumbnails/{thumb_filename}"))
|
||||||
|
|||||||
@@ -81,17 +81,18 @@ impl ConfigCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache miss or stale — reload the entire table in one query.
|
// Cache miss or stale — reload the entire table in one query.
|
||||||
let rows: Vec<(String, String)> =
|
let rows: Vec<(String, String)> = match sqlx::query_as::<_, (String, String)>(
|
||||||
match sqlx::query_as::<_, (String, String)>("SELECT key, value FROM config")
|
"SELECT key, value FROM config",
|
||||||
.fetch_all(&self.pool)
|
)
|
||||||
.await
|
.fetch_all(&self.pool)
|
||||||
{
|
.await
|
||||||
Ok(rows) => rows,
|
{
|
||||||
Err(e) => {
|
Ok(rows) => rows,
|
||||||
tracing::warn!(error = ?e, "config reload failed; using defaults for this read");
|
Err(e) => {
|
||||||
return None;
|
tracing::warn!(error = ?e, "config reload failed; using defaults for this read");
|
||||||
}
|
return None;
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let values: HashMap<String, String> = rows.into_iter().collect();
|
let values: HashMap<String, String> = rows.into_iter().collect();
|
||||||
let result = values.get(key).cloned();
|
let result = values.get(key).cloned();
|
||||||
@@ -104,26 +105,43 @@ impl ConfigCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_str(cache: &ConfigCache, key: &str, default: &str) -> String {
|
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 {
|
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 {
|
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 {
|
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.
|
/// 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
|
/// Accepts `true/false`, `1/0`, `yes/no`, `on/off` — case-insensitive. Anything else
|
||||||
/// returns `default`.
|
/// returns `default`.
|
||||||
pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool {
|
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() {
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
"true" | "1" | "yes" | "on" => true,
|
"true" | "1" | "yes" | "on" => true,
|
||||||
"false" | "0" | "no" | "off" => false,
|
"false" | "0" | "no" | "off" => false,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! barely moves. [`DiskCache`] refreshes it at most once per [`TTL`] and serves the
|
//! barely moves. [`DiskCache`] refreshes it at most once per [`TTL`] and serves the
|
||||||
//! rest from memory.
|
//! rest from memory.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ pub struct DiskInfo {
|
|||||||
/// `AppState`.
|
/// `AppState`.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DiskCache {
|
pub struct DiskCache {
|
||||||
inner: Arc<RwLock<Option<(DiskInfo, Instant)>>>,
|
inner: Arc<RwLock<Option<(PathBuf, DiskInfo, Instant)>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DiskCache {
|
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`.
|
/// 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
|
/// 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
|
/// "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.)
|
/// `None`: enforcing a 0-byte limit would lock every user out of uploading.)
|
||||||
pub fn snapshot(&self, media_path: &Path) -> Option<DiskInfo> {
|
/// Cached free-space reading for `path`.
|
||||||
if let Some((info, at)) = *self.inner.read().unwrap() {
|
///
|
||||||
if at.elapsed() < TTL {
|
/// The cache is keyed BY PATH. It used to hold a single slot and ignore its argument on a hit,
|
||||||
return Some(info);
|
/// 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)?;
|
let info = read_disk_for_path(path)?;
|
||||||
*self.inner.write().unwrap() = Some((info, Instant::now()));
|
*self.inner.write().unwrap() = Some((path.to_path_buf(), info, Instant::now()));
|
||||||
Some(info)
|
Some(info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,20 +123,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn picks_longest_matching_mount() {
|
fn picks_longest_matching_mount() {
|
||||||
// Both "/" and "/media" prefix the path; the dedicated volume must win.
|
// Both "/" and "/media" prefix the path; the dedicated volume must win.
|
||||||
let mounts = vec![
|
let mounts = vec![("/".to_string(), 100, 40), ("/media".to_string(), 200, 150)];
|
||||||
("/".to_string(), 100, 40),
|
|
||||||
("/media".to_string(), 200, 150),
|
|
||||||
];
|
|
||||||
let d = select_disk(&mounts, "/media/originals/x.jpg").unwrap();
|
let d = select_disk(&mounts, "/media/originals/x.jpg").unwrap();
|
||||||
assert_eq!((d.total, d.free), (200, 150));
|
assert_eq!((d.total, d.free), (200, 150));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn falls_back_to_root_when_no_specific_mount_matches() {
|
fn falls_back_to_root_when_no_specific_mount_matches() {
|
||||||
let mounts = vec![
|
let mounts = vec![("/".to_string(), 100, 40), ("/media".to_string(), 200, 150)];
|
||||||
("/".to_string(), 100, 40),
|
|
||||||
("/media".to_string(), 200, 150),
|
|
||||||
];
|
|
||||||
// "/var/lib" is only prefixed by "/".
|
// "/var/lib" is only prefixed by "/".
|
||||||
let d = select_disk(&mounts, "/var/lib/data").unwrap();
|
let d = select_disk(&mounts, "/var/lib/data").unwrap();
|
||||||
assert_eq!((d.total, d.free), (100, 40));
|
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
|
// 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
|
// failed-but-released jobs from `main` once `AppState` exists (it needs the media
|
||||||
// paths + SSE sender this fn doesn't have).
|
// 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(
|
match sqlx::query(
|
||||||
"UPDATE export_job
|
"UPDATE export_job
|
||||||
SET status = 'failed',
|
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)
|
/// - drops expired SSE tickets (30s TTL but the map keeps the slot until pruned)
|
||||||
///
|
///
|
||||||
/// Cadence is 1h — fine for both jobs at our scale.
|
/// Cadence is 1h — fine for both jobs at our scale.
|
||||||
pub fn spawn_periodic_tasks(
|
pub fn spawn_periodic_tasks(pool: PgPool, rate_limiter: RateLimiter, sse_tickets: SseTicketStore) {
|
||||||
pool: PgPool,
|
|
||||||
rate_limiter: RateLimiter,
|
|
||||||
sse_tickets: SseTicketStore,
|
|
||||||
) {
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
||||||
// Fire the first tick immediately, then hourly.
|
// Fire the first tick immediately, then hourly.
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ pub mod compression;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod disk;
|
pub mod disk;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod jobs;
|
|
||||||
pub mod maintenance;
|
pub mod maintenance;
|
||||||
pub mod rate_limiter;
|
pub mod rate_limiter;
|
||||||
pub mod sse_tickets;
|
pub mod sse_tickets;
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ impl RateLimiter {
|
|||||||
|
|
||||||
/// Returns `Ok(())` if allowed, `Err(retry_after_secs)` if rate-limited.
|
/// 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.
|
/// `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 now = Instant::now();
|
||||||
let key = key.into();
|
let key = key.into();
|
||||||
let mut map = self.windows.lock().unwrap();
|
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));
|
||||||
assert!(!rl.check("k", 1, w));
|
assert!(!rl.check("k", 1, w));
|
||||||
std::thread::sleep(Duration::from_millis(55));
|
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]
|
#[test]
|
||||||
fn retry_after_is_between_one_and_window() {
|
fn retry_after_counts_down_as_the_window_elapses() {
|
||||||
let rl = RateLimiter::new();
|
let rl = RateLimiter::new();
|
||||||
assert!(rl.check_with_retry("k", 1, MIN).is_ok());
|
let w = Duration::from_secs(30);
|
||||||
let retry = rl.check_with_retry("k", 1, MIN).unwrap_err();
|
assert!(rl.check_with_retry("k", 1, w).is_ok());
|
||||||
assert!((1..=60).contains(&retry), "retry_after {retry} out of range");
|
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]
|
#[test]
|
||||||
@@ -140,6 +197,59 @@ mod tests {
|
|||||||
assert!(rl.check("k", 1, MIN), "clear() must free the window");
|
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]
|
#[test]
|
||||||
fn client_ip_takes_rightmost_forwarded_for_entry() {
|
fn client_ip_takes_rightmost_forwarded_for_entry() {
|
||||||
// The right-most entry is the hop our trusted proxy (Caddy) appended.
|
// 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() {
|
fn client_ip_ignores_spoofed_leftmost_entry() {
|
||||||
// A client prepending a fake IP to dodge throttles must not win.
|
// A client prepending a fake IP to dodge throttles must not win.
|
||||||
let mut h = HeaderMap::new();
|
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");
|
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).
|
/// Mint a new ticket bound to the caller's session (identified by token hash).
|
||||||
pub fn issue(&self, token_hash: String) -> String {
|
pub fn issue(&self, token_hash: String) -> String {
|
||||||
let ticket = random_ticket();
|
let ticket = random_ticket();
|
||||||
@@ -83,7 +90,11 @@ mod tests {
|
|||||||
let ticket = store.issue("hash-1".into());
|
let ticket = store.issue("hash-1".into());
|
||||||
assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1"));
|
assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1"));
|
||||||
// Single-use: a replay of the same ticket is rejected.
|
// 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]
|
#[test]
|
||||||
@@ -124,6 +135,10 @@ mod tests {
|
|||||||
.expect("host uptime should exceed the ticket TTL"),
|
.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
|
- **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
|
uploads (a banned user's content is "gone" everywhere) — there is no opt-out. Submitting
|
||||||
calls `POST /host/users/{id}/ban` (no body).
|
calls `POST /host/users/{id}/ban` (no body).
|
||||||
- **Entsperren** lifts the ban.
|
- **Entsperren** lifts the ban. Same authority boundary as ban (below): a plain Host may
|
||||||
- **Host** promotes a guest to host.
|
only unban Guests; only an Admin may unban a Host.
|
||||||
- **Degradieren** — visible on Host rows. A Host can demote *other* Hosts back to
|
- **Host** promotes a guest to host (Hosts and Admins may do this).
|
||||||
guest (planned). The button is hidden on the Host's own row to prevent self-lockout;
|
- **Degradieren** — demote a Host back to guest. **Only an Admin may change a Host's
|
||||||
only an Admin can demote themselves out of moderation. Admins see Degradieren on
|
role.** A plain Host may *not* demote a peer Host: doing so would let them then ban or
|
||||||
every Host row.
|
PIN-reset (→ `/recover` account-takeover) that ex-peer, since those guards key off the
|
||||||
- **PIN zurücksetzen** (planned) — generates a new PIN and shows it once in a modal.
|
target's *current* role. So the backend rejects it (403) and the button is hidden for
|
||||||
See journey §4. Hosts see this on Guest rows only; Admins see it on Guest + Host
|
non-admin Hosts. Nobody may change their own role (self-lockout / self-escalation guard),
|
||||||
rows.
|
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
|
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
|
(`DELETE /host/upload/{id}`, `DELETE /host/comment/{id}`). On mobile this is also
|
||||||
reachable by long-pressing the content (planned, see §15).
|
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
|
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
|
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
|
`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
|
## 11. Admin — instance configuration
|
||||||
|
|
||||||
@@ -184,7 +193,18 @@ the Host can clean up later).
|
|||||||
## 12. Releasing the export and downloading
|
## 12. Releasing the export and downloading
|
||||||
|
|
||||||
1. Host (or Admin) taps **Galerie freigeben** in the dashboard.
|
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
|
3. ZIP job: streams `Gallery.zip` (`Photos/` + `Videos/`, full-quality originals) directly
|
||||||
to disk via `async-zip`. Progress updates via `export-progress` SSE.
|
to disk via `async-zip`. Progress updates via `export-progress` SSE.
|
||||||
4. HTML-viewer job: copies the pre-built viewer assets from
|
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`
|
`include_dir!`), generates `data.json` from the database, processes `_thumb`/`_full`
|
||||||
variants for each upload, and assembles `Memories.zip`.
|
variants for each upload, and assembles `Memories.zip`.
|
||||||
5. Both jobs complete → server broadcasts `export-available` SSE.
|
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`:
|
6. Any user opens `/export`:
|
||||||
- Before release: friendly "Export not yet available" banner.
|
- Before release: friendly "Export not yet available" banner.
|
||||||
- During generation: progress bars per artifact.
|
- 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.
|
against a real Rust backend with rate limits and quotas disabled.
|
||||||
|
|
||||||
**Phases 1, 2, and 3-mobile-gestures are landed**:
|
**Phases 1, 2, and 3-mobile-gestures are landed**:
|
||||||
|
|
||||||
- **Phase 1** — happy-path coverage of every documented user journey, plus a
|
- **Phase 1** — happy-path coverage of every documented user journey, plus a
|
||||||
smoke matrix across nine browser/UA profiles to catch engine-level
|
smoke matrix across nine browser/UA profiles to catch engine-level
|
||||||
divergences.
|
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)
|
Every spec covers a journey from [`docs/USER_JOURNEYS.md`](../docs/USER_JOURNEYS.md)
|
||||||
or a security/chaos scenario. One folder per area:
|
or a security/chaos scenario. One folder per area:
|
||||||
|
|
||||||
| Folder | Phase | Journeys / Topic | Tests | Notes |
|
| 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/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/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/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/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/05-admin/` | 1 | §11, §16 | 11 | Config validation, foundational auth guards, stats. |
|
||||||
| `specs/06-export/` | 1 | §12 | 3 | Status, release, download stub. |
|
| `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/__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/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/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. |
|
| `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/`)
|
### Phase 2 — adversarial (`specs/07-adversarial/`)
|
||||||
|
|
||||||
- **`xss-injection.spec.ts`** — 13 tests. Six XSS payloads × display-name path
|
- **`xss-injection.spec.ts`** — 13 tests. Six XSS payloads × display-name path
|
||||||
+ four SQLi patterns + length/encoding edge cases (NUL byte, RTL override,
|
- four SQLi patterns + length/encoding edge cases (NUL byte, RTL override,
|
||||||
caption overflow). Asserts `window.__xssFired` never gets set and no
|
caption overflow). Asserts `window.__xssFired` never gets set and no
|
||||||
`dialog` event fires.
|
`dialog` event fires.
|
||||||
- **`ui-rendering.spec.ts`** — 2 tests. Belt-and-braces: even when a script-
|
- **`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
|
payload sits in localStorage as the user's display name, rendering through
|
||||||
`/account` keeps it as text.
|
`/account` keeps it as text.
|
||||||
@@ -104,17 +105,17 @@ are marked `test.fixme` and will activate when that helper lands.
|
|||||||
|
|
||||||
## Browser & UA matrix
|
## Browser & UA matrix
|
||||||
|
|
||||||
| Project | Engine | UA / Device | Why |
|
| Project | Engine | UA / Device | Why |
|
||||||
|---|---|---|---|
|
| --------------------- | -------- | ----------------------------------- | --------------------------------------------- |
|
||||||
| `chromium-desktop` | Chromium | Desktop Chrome | Baseline. Full suite runs here. |
|
| `chromium-desktop` | Chromium | Desktop Chrome | Baseline. Full suite runs here. |
|
||||||
| `chromium-pixel7` | Chromium | Pixel 7 device descriptor | Chrome Android. |
|
| `chromium-pixel7` | Chromium | Pixel 7 device descriptor | Chrome Android. |
|
||||||
| `chromium-galaxy-s22` | Chromium | Galaxy viewport + Samsung phone UA | Chrome on Samsung hardware. |
|
| `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.** |
|
| `samsung-internet` | Chromium | Galaxy viewport + SamsungBrowser UA | **Tier-A Samsung Internet baseline.** |
|
||||||
| `edge-android` | Chromium | Pixel viewport + EdgA UA | Edge Mobile (Blink-based). |
|
| `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). |
|
| `chrome-ios` | Chromium | iPhone viewport + CriOS UA | Chrome iOS (actually WebKit, but UA differs). |
|
||||||
| `webkit-iphone` | WebKit | iPhone 14 Pro | Real iOS Safari engine. |
|
| `webkit-iphone` | WebKit | iPhone 14 Pro | Real iOS Safari engine. |
|
||||||
| `firefox-android` | Firefox | Pixel viewport + Firefox Android UA | Gecko engine. |
|
| `firefox-android` | Firefox | Pixel viewport + Firefox Android UA | Gecko engine. |
|
||||||
| `firefox-desktop` | Firefox | Desktop Firefox | FF-specific quirks. |
|
| `firefox-desktop` | Firefox | Desktop Firefox | FF-specific quirks. |
|
||||||
|
|
||||||
Only the `@smoke` happy-path runs across all projects (controlled by
|
Only the `@smoke` happy-path runs across all projects (controlled by
|
||||||
`grep` in `playwright.config.ts`). The full Phase 1 suite is
|
`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
|
divergences (Smart Switch save-data mode, dark-mode injection, custom
|
||||||
autoplay, in-browser ad blocking) are only reproducible at Tier B+:
|
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`
|
Samsung Internet user-agent + Galaxy viewport. See the `samsung-internet`
|
||||||
project in `playwright.config.ts`.
|
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` →
|
install Samsung Internet APK → enable `--remote-debugging-port=9222` →
|
||||||
`chromium.connectOverCDP('http://localhost:9222')`. Setup docs live in
|
`chromium.connectOverCDP('http://localhost:9222')`. Setup docs live in
|
||||||
`docs/samsung-emulator.md` (to be written).
|
`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.
|
Real Galaxy S22/S23 hardware via Playwright's cloud integration.
|
||||||
|
|
||||||
## Test isolation
|
## Test isolation
|
||||||
@@ -223,7 +224,7 @@ Known findings surfaced (documented in tests, not silent failures):
|
|||||||
clears it).
|
clears it).
|
||||||
3. SVG uploads currently pass the magic-byte check (depends on `infer`'s
|
3. SVG uploads currently pass the magic-byte check (depends on `infer`'s
|
||||||
detection coverage) — consider adding `X-Content-Type-Options: nosniff`
|
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
|
### Phase 3 — Mobile gestures (`specs/09-mobile/`) ✅ landed
|
||||||
|
|
||||||
@@ -273,6 +274,7 @@ ignores this folder via `testIgnore` in [playwright.config.ts](playwright.config
|
|||||||
values.
|
values.
|
||||||
|
|
||||||
### Phase 3 — Real-device compat & visual / a11y (not landed)
|
### 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.
|
- 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.
|
- Safe-area inset visual diff on iPhone notch.
|
||||||
- Touch-target ≥ 44 px audit.
|
- 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.
|
- Visual regression with screenshot diffs.
|
||||||
|
|
||||||
### Out of scope (handed to other tools)
|
### Out of scope (handed to other tools)
|
||||||
|
|
||||||
- Load testing → k6 / Vegeta.
|
- Load testing → k6 / Vegeta.
|
||||||
- API contract testing → backend `cargo test` integration tests.
|
- API contract testing → backend `cargo test` integration tests.
|
||||||
- Static asset auditing → Lighthouse CI.
|
- Static asset auditing → Lighthouse CI.
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ services:
|
|||||||
POSTGRES_PASSWORD: eventsnap_test
|
POSTGRES_PASSWORD: eventsnap_test
|
||||||
POSTGRES_DB: eventsnap_test
|
POSTGRES_DB: eventsnap_test
|
||||||
healthcheck:
|
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
|
interval: 3s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 30
|
retries: 30
|
||||||
ports:
|
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:
|
app:
|
||||||
build:
|
build:
|
||||||
@@ -40,15 +40,15 @@ services:
|
|||||||
ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
|
ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
|
||||||
EVENT_SLUG: e2e-test-event
|
EVENT_SLUG: e2e-test-event
|
||||||
EVENT_NAME: E2E Test Event
|
EVENT_NAME: E2E Test Event
|
||||||
APP_PORT: "3000"
|
APP_PORT: '3000'
|
||||||
MEDIA_PATH: /media
|
MEDIA_PATH: /media
|
||||||
SESSION_EXPIRY_DAYS: "30"
|
SESSION_EXPIRY_DAYS: '30'
|
||||||
EVENTSNAP_TEST_MODE: "1" # ENABLES /admin/__truncate — never set in prod
|
EVENTSNAP_TEST_MODE: '1' # ENABLES /admin/__truncate — never set in prod
|
||||||
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
||||||
volumes:
|
volumes:
|
||||||
- media_data:/media
|
- media_data:/media
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- '3000'
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -57,11 +57,11 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
environment:
|
environment:
|
||||||
PORT: "3001"
|
PORT: '3001'
|
||||||
HOST: "0.0.0.0"
|
HOST: '0.0.0.0'
|
||||||
ORIGIN: "http://localhost:3101"
|
ORIGIN: 'http://localhost:3101'
|
||||||
expose:
|
expose:
|
||||||
- "3001"
|
- '3001'
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
@@ -71,7 +71,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile.test:/etc/caddy/Caddyfile:ro
|
- ./Caddyfile.test:/etc/caddy/Caddyfile:ro
|
||||||
ports:
|
ports:
|
||||||
- "3101:3101"
|
- '3101:3101'
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
media_data:
|
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 ───────────────────────────────────────────────────────────────
|
// ── 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', {
|
const { body } = await this.request<any>('POST', '/join', {
|
||||||
body: { display_name: displayName },
|
body: { display_name: displayName },
|
||||||
expectedStatus: [201],
|
expectedStatus: [201],
|
||||||
@@ -55,7 +57,11 @@ export class ApiClient {
|
|||||||
return body;
|
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', {
|
return this.request<any>('POST', '/recover', {
|
||||||
body: { display_name: displayName, pin },
|
body: { display_name: displayName, pin },
|
||||||
expectedStatus: opts.expectedStatus ?? [200],
|
expectedStatus: opts.expectedStatus ?? [200],
|
||||||
@@ -91,7 +97,9 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getConfig(adminToken: string): Promise<Record<string, string>> {
|
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;
|
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`, {
|
return this.request<void>('POST', `/host/users/${userId}/ban`, {
|
||||||
token,
|
token,
|
||||||
body: { hide_uploads: hideUploads },
|
|
||||||
expectedStatus: [200, 204],
|
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`, {
|
return this.request<void>('POST', `/host/users/${userId}/unban`, {
|
||||||
token,
|
token,
|
||||||
expectedStatus: opts.expectedStatus ?? [200, 204],
|
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) {
|
async closeEvent(token: string) {
|
||||||
return this.request<void>('POST', '/host/event/close', { token, expectedStatus: [200, 204] });
|
return this.request<void>('POST', '/host/event/close', { token, expectedStatus: [200, 204] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,16 @@ export const db = {
|
|||||||
|
|
||||||
async expireSession(userId: string) {
|
async expireSession(userId: string) {
|
||||||
await withClient((c) =>
|
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) =>
|
await withClient((c) =>
|
||||||
c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
|
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) {
|
async setExportReleased(slug: string, released: boolean) {
|
||||||
await withClient((c) =>
|
await withClient((c) =>
|
||||||
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
|
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
|
* Make an export "ready" (or not) in the epoch model. There is no `export_zip_ready` column any
|
||||||
* only when this boolean is true AND the file exists on disk, so setting it true
|
* more — readiness is DERIVED (`released AND job.epoch = event.export_epoch AND status='done'`),
|
||||||
* without a file lets tests exercise the "ready but file missing" 404 branch.
|
* 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) {
|
async setExportZipReady(slug: string, ready: boolean) {
|
||||||
await withClient((c) =>
|
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) => {
|
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}`);
|
if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`);
|
||||||
await c.query(
|
await c.query(
|
||||||
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at)
|
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at, epoch)
|
||||||
VALUES ($1, $2::export_type, $3::export_status, $4, $5)
|
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`,
|
ON CONFLICT (event_id, type) DO UPDATE
|
||||||
[ev.rows[0].id, type, status, status === 'done' ? 100 : 0, status === 'done' ? new Date() : null]
|
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) => {
|
const fn = async (page: Page, handle: GuestHandle) => {
|
||||||
// Visit any in-app URL first so localStorage is scoped to the right origin.
|
// Visit any in-app URL first so localStorage is scoped to the right origin.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.evaluate(
|
await page.evaluate(({ jwt, pin, userId, displayName }) => {
|
||||||
({ jwt, pin, userId, displayName }) => {
|
localStorage.setItem('eventsnap_jwt', jwt);
|
||||||
localStorage.setItem('eventsnap_jwt', jwt);
|
localStorage.setItem('eventsnap_pin', pin);
|
||||||
localStorage.setItem('eventsnap_pin', pin);
|
localStorage.setItem('eventsnap_user_id', userId);
|
||||||
localStorage.setItem('eventsnap_user_id', userId);
|
localStorage.setItem('eventsnap_display_name', displayName);
|
||||||
localStorage.setItem('eventsnap_display_name', displayName);
|
localStorage.setItem('eventsnap_guide_seen', 'true');
|
||||||
localStorage.setItem('eventsnap_guide_seen', 'true');
|
}, handle);
|
||||||
},
|
|
||||||
handle
|
|
||||||
);
|
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
};
|
};
|
||||||
await use(fn);
|
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 { join } from 'node:path';
|
||||||
import { uploadRaw } from './upload-client';
|
import { uploadRaw } from './upload-client';
|
||||||
import { db } from '../fixtures/db';
|
import { db } from '../fixtures/db';
|
||||||
|
import { BASE } from './env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
// A real, decodable JPEG. Magic-bytes-only fakes now get auto-cleaned by the
|
// 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
|
// backend when compression fails (a failed transcode refunds quota + deletes the
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export class SseListener {
|
|||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
(async () => {
|
// Fire-and-forget reader loop; the caller drives lifecycle via stop()/the AbortController.
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
while (!this.closed) {
|
while (!this.closed) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
@@ -61,7 +62,9 @@ export class SseListener {
|
|||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
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[] {
|
allEvents(): SseEvent[] {
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
* it lives in one place instead of being re-inlined per spec.
|
* it lives in one place instead of being re-inlined per spec.
|
||||||
*/
|
*/
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
import { BASE } from './env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
/** Exchange a JWT for a single-use SSE ticket via POST /api/v1/stream/ticket. */
|
/** Exchange a JWT for a single-use SSE ticket via POST /api/v1/stream/ticket. */
|
||||||
export async function mintSseTicket(jwt: string): Promise<string> {
|
export async function mintSseTicket(jwt: string): Promise<string> {
|
||||||
@@ -13,7 +12,8 @@ export async function mintSseTicket(jwt: string): Promise<string> {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${jwt}` },
|
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;
|
return (await res.json()).ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,47 @@ export async function longPress(page: Page, locator: Locator, durationMs = 600)
|
|||||||
await page.mouse.up();
|
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.
|
* 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
|
* Node-side multipart upload helper. Lets adversarial specs post arbitrary
|
||||||
* bytes with arbitrary `Content-Type` claims to /api/v1/upload without
|
* 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.
|
// Node 22+ ships FormData and Blob as globals — no import needed.
|
||||||
|
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
export type UploadOptions = {
|
export type UploadOptions = {
|
||||||
filename?: string;
|
filename?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
@@ -20,7 +19,11 @@ export type UploadOptions = {
|
|||||||
hashtags?: string;
|
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 form = new FormData();
|
||||||
const blob = new Blob([body as any], { type: opts.contentType ?? 'application/octet-stream' });
|
const blob = new Blob([body as any], { type: opts.contentType ?? 'application/octet-stream' });
|
||||||
form.append('file', blob as any, opts.filename ?? 'upload.bin');
|
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. */
|
/** 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. */
|
/** Tiny valid PNG magic bytes. */
|
||||||
export const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
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`. */
|
/** ELF header — the magic bytes of a Linux executable. `infer` reports `application/x-executable`. */
|
||||||
export const ELF_MAGIC = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]);
|
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:up": "docker compose -f docker-compose.test.yml up -d --build",
|
||||||
"stack:down": "docker compose -f docker-compose.test.yml down -v",
|
"stack:down": "docker compose -f docker-compose.test.yml down -v",
|
||||||
"stack:logs": "docker compose -f docker-compose.test.yml logs -f",
|
"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": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.5",
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"pg": "^8.13.1",
|
|
||||||
"@types/pg": "^8.11.10",
|
"@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';
|
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 {
|
export class ExportPage {
|
||||||
readonly page: Page;
|
readonly page: Page;
|
||||||
|
/** Empty state shown while the host has not released the export yet. */
|
||||||
readonly notAvailableBanner: Locator;
|
readonly notAvailableBanner: Locator;
|
||||||
|
/** Any "Download" button on the page (both cards). Useful for absence assertions. */
|
||||||
|
readonly downloadButtons: Locator;
|
||||||
readonly zipDownloadButton: Locator;
|
readonly zipDownloadButton: Locator;
|
||||||
readonly htmlDownloadButton: Locator;
|
readonly htmlDownloadButton: Locator;
|
||||||
|
/** Confirm button inside the "Hinweis zum HTML-Viewer" modal. */
|
||||||
readonly htmlGuideModalContinue: Locator;
|
readonly htmlGuideModalContinue: Locator;
|
||||||
|
|
||||||
constructor(page: Page) {
|
constructor(page: Page) {
|
||||||
this.page = page;
|
this.page = page;
|
||||||
this.notAvailableBanner = page.locator('[data-testid="export-not-available"]');
|
this.notAvailableBanner = page.getByText('Export noch nicht verfügbar');
|
||||||
this.zipDownloadButton = page.getByRole('button', { name: /zip.*herunter|herunter.*zip/i }).first();
|
this.downloadButtons = page.getByRole('button', { name: 'Download', exact: true });
|
||||||
this.htmlDownloadButton = page.getByRole('button', { name: /html.*herunter|herunter.*html/i }).first();
|
this.zipDownloadButton = this.cardButton('ZIP-Archiv');
|
||||||
this.htmlGuideModalContinue = page.getByRole('button', { name: /^herunterladen$/i });
|
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() {
|
async goto() {
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ export class HostDashboard {
|
|||||||
constructor(page: Page) {
|
constructor(page: Page) {
|
||||||
this.page = page;
|
this.page = page;
|
||||||
this.statsSection = page.locator('[data-testid="host-stats"]');
|
this.statsSection = page.locator('[data-testid="host-stats"]');
|
||||||
this.lockEventButton = page.getByRole('button', { name: /uploads sperren|event sperren|sperren/i }).first();
|
this.lockEventButton = page
|
||||||
this.unlockEventButton = page.getByRole('button', { name: /uploads freigeben|entsperren/i }).first();
|
.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.releaseGalleryButton = page.getByRole('button', { name: /galerie freigeben/i });
|
||||||
this.userSearchInput = page.getByPlaceholder(/suche|search/i).first();
|
this.userSearchInput = page.getByPlaceholder(/suche|search/i).first();
|
||||||
}
|
}
|
||||||
@@ -22,15 +26,23 @@ export class HostDashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userRow(displayName: string): Locator {
|
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);
|
const row = this.userRow(displayName);
|
||||||
await row.getByRole('button', { name: /sperren/i }).first().click();
|
await row
|
||||||
if (hideUploads) {
|
.getByRole('button', { name: /sperren/i })
|
||||||
await this.page.getByRole('checkbox', { name: /ausblenden/i }).check();
|
.first()
|
||||||
}
|
.click();
|
||||||
await this.page.getByRole('button', { name: /bestätigen|sperren/i }).last().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({
|
export default defineConfig({
|
||||||
testDir: './specs',
|
testDir: './specs',
|
||||||
outputDir: './test-results',
|
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,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
// NO RETRIES — not even in CI. This is deliberate and it is the opposite of the usual advice.
|
||||||
workers: 1, // One worker. Multi-worker needs per-worker isolated DBs (Phase 2+).
|
//
|
||||||
reporter: [
|
// The standard case for retries is "the environment is flaky, the product isn't." That argument
|
||||||
['list'],
|
// does not hold here. This backend's real bugs ARE races (the last several fixes were all export
|
||||||
['html', { open: 'never', outputFolder: './playwright-report' }],
|
// 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',
|
globalSetup: './global-setup.ts',
|
||||||
globalTeardown: './global-teardown.ts',
|
globalTeardown: './global-teardown.ts',
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
@@ -86,7 +101,7 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
name: 'chromium-galaxy-s22',
|
name: 'chromium-galaxy-s22',
|
||||||
use: {
|
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 },
|
viewport: { width: 360, height: 780 },
|
||||||
userAgent:
|
userAgent:
|
||||||
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
'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',
|
name: 'webkit-iphone',
|
||||||
use: { ...devices['iPhone 14 Pro'] },
|
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',
|
name: 'firefox-android',
|
||||||
@@ -137,8 +157,7 @@ export default defineConfig({
|
|||||||
// Firefox rejects `isMobile` (Chromium-only). Keep the phone viewport +
|
// Firefox rejects `isMobile` (Chromium-only). Keep the phone viewport +
|
||||||
// Android UA for coverage, but drop the unsupported flag.
|
// Android UA for coverage, but drop the unsupported flag.
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
userAgent:
|
userAgent: 'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0',
|
||||||
'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0',
|
|
||||||
},
|
},
|
||||||
grep: /@smoke/,
|
grep: /@smoke/,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,17 +13,24 @@ test.describe('Auth — admin login', () => {
|
|||||||
await login.login(ADMIN_PASSWORD);
|
await login.login(ADMIN_PASSWORD);
|
||||||
await page.waitForURL('**/admin', { timeout: 10_000 });
|
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)
|
// The admin JWT lands in sessionStorage (USER_JOURNEYS §11.1) — NOT localStorage — so the
|
||||||
const role = await page.evaluate(() => {
|
// elevated credential doesn't survive a tab/browser close on a shared "event laptop".
|
||||||
const token = localStorage.getItem('eventsnap_jwt');
|
const stores = await page.evaluate(() => {
|
||||||
if (!token) return null;
|
const decodeRole = (t: string | null) => {
|
||||||
try {
|
if (!t) return null;
|
||||||
return JSON.parse(atob(token.split('.')[1])).role;
|
try {
|
||||||
} catch {
|
return JSON.parse(atob(t.split('.')[1])).role;
|
||||||
return null;
|
} 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 }) => {
|
test('wrong password → error, no token written', async ({ page }) => {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
|
||||||
test.describe('Navigation — back chevrons', () => {
|
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');
|
await page.goto('/recover');
|
||||||
const back = page.getByTestId('recover-back');
|
const back = page.getByTestId('recover-back');
|
||||||
await expect(back).toBeVisible();
|
await expect(back).toBeVisible();
|
||||||
@@ -15,7 +17,11 @@ test.describe('Navigation — back chevrons', () => {
|
|||||||
await page.waitForURL(/\/(join|feed)$/);
|
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');
|
const g = await guest('ExportBack');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/export');
|
await page.goto('/export');
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ test.describe('Auth — join flow', () => {
|
|||||||
await page.waitForURL('**/feed', { timeout: 5_000 });
|
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');
|
const original = await guest('Charlie');
|
||||||
|
|
||||||
// Brand-new browser context (cleared storage) — landing on /join with same name
|
// 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';
|
import { readStorage } from '../../helpers/storage-helpers';
|
||||||
|
|
||||||
test.describe('Auth — leave event', () => {
|
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');
|
const h = await guest('Jens');
|
||||||
await signIn(page, h);
|
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';
|
import { clearAllStorage } from '../../helpers/storage-helpers';
|
||||||
|
|
||||||
test.describe('Auth — PIN auto-submit', () => {
|
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');
|
const original = await guest('AutoInline');
|
||||||
await clearAllStorage(page);
|
await clearAllStorage(page);
|
||||||
|
|
||||||
@@ -30,7 +33,10 @@ test.describe('Auth — PIN auto-submit', () => {
|
|||||||
await page.waitForURL('**/feed', { timeout: 5_000 });
|
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');
|
const original = await guest('AutoRecover');
|
||||||
await clearAllStorage(page);
|
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);
|
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);
|
await clearAllStorage(page);
|
||||||
const recover = new RecoverPage(page);
|
const recover = new RecoverPage(page);
|
||||||
|
|
||||||
await recover.goto();
|
await recover.goto();
|
||||||
await recover.recover('Doesnt-Exist-' + Date.now(), '1234');
|
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 }) => {
|
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 h = await guest('Uploader2');
|
||||||
const { uploadRaw } = await import('../../helpers/upload-client');
|
const { uploadRaw } = await import('../../helpers/upload-client');
|
||||||
const { readFileSync } = await import('node:fs');
|
const { readFileSync } = await import('node:fs');
|
||||||
const r1 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG), { filename: 'a.jpg', contentType: 'image/jpeg' });
|
const r1 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG), {
|
||||||
const r2 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG_2), { filename: 'b.jpg', contentType: 'image/jpeg' });
|
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(r1.status).toBe(201);
|
||||||
expect(r2.status).toBe(201);
|
expect(r2.status).toBe(201);
|
||||||
await expect.poll(() => db.countUploadsForUser(h.userId), { timeout: 10_000 }).toBe(2);
|
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
|
// 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
|
// navigation/blob timing quirk but an IndexedDB upgrade bug — the v1→v2
|
||||||
// `upgrade` callback opened a *new* transaction, which throws during a
|
// `upgrade` callback opened a *new* transaction, which throws during a
|
||||||
@@ -74,13 +85,20 @@ test.describe('Upload — gallery path', () => {
|
|||||||
const { id } = await res.json();
|
const { id } = await res.json();
|
||||||
|
|
||||||
// Wait up to 10s for the compression worker's SSE.
|
// Wait up to 10s for the compression worker's SSE.
|
||||||
const evt = await sse.waitForEvent('upload-processed', (e) => {
|
const evt = await sse
|
||||||
const data = typeof e.data === 'string' ? safeJson(e.data) : e.data;
|
.waitForEvent(
|
||||||
return data?.upload_id === id || data?.id === id;
|
'upload-processed',
|
||||||
}, 10_000).catch(() => null);
|
(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 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 {
|
} finally {
|
||||||
sse.stop();
|
sse.stop();
|
||||||
}
|
}
|
||||||
@@ -88,5 +106,9 @@ test.describe('Upload — gallery path', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function safeJson(s: string) {
|
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);
|
const SAMPLE_BYTES = readFileSync(SAMPLE_JPG);
|
||||||
|
|
||||||
test.describe('Upload — rate limit', () => {
|
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,
|
// 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
|
// so the config can't be reset out from under us by the ordering of hooks
|
||||||
// vs fixtures.
|
// vs fixtures.
|
||||||
@@ -53,7 +57,11 @@ test.describe('Upload — rate limit', () => {
|
|||||||
expect(limited.headers.get('retry-after')).toBeTruthy();
|
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' });
|
await api.patchConfig(adminToken, { upload_rate_enabled: 'false' });
|
||||||
|
|
||||||
const h = await guest('NoQuota');
|
const h = await guest('NoQuota');
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { seedUpload } from '../../helpers/seed';
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Comments — UI round-trip (CR1)', () => {
|
test.describe('Comments — UI round-trip (CR1)', () => {
|
||||||
test('a comment typed in the lightbox persists to the backend', async ({
|
test('a comment typed in the lightbox persists to the backend', async ({
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { SseListener } from '../../helpers/sse-listener';
|
import { SseListener } from '../../helpers/sse-listener';
|
||||||
import { seedUpload, seedComment, findFeedRow } from '../../helpers/seed';
|
import { seedUpload, seedComment, findFeedRow } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
async function like(jwt: string, uploadId: string): Promise<number> {
|
async function like(jwt: string, uploadId: string): Promise<number> {
|
||||||
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, {
|
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.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 author = await guest('Author');
|
||||||
const liker = await guest('Liker');
|
const liker = await guest('Liker');
|
||||||
const uploadId = await seedUpload(author.jwt);
|
const uploadId = await seedUpload(author.jwt);
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { test, expect } from '../../fixtures/test';
|
|||||||
import { trackStreamOpens } from '../../helpers/sse';
|
import { trackStreamOpens } from '../../helpers/sse';
|
||||||
|
|
||||||
test.describe('Feed — SSE behavior', () => {
|
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 g = await guest('SseReconnect');
|
||||||
const streamOpens = trackStreamOpens(page);
|
const streamOpens = trackStreamOpens(page);
|
||||||
|
|
||||||
@@ -25,7 +29,10 @@ test.describe('Feed — SSE behavior', () => {
|
|||||||
// new stream opens while hidden.
|
// new stream opens while hidden.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
|
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'));
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
});
|
});
|
||||||
// Snapshot the count AFTER backgrounding — this baselines out the initial open (and
|
// 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.
|
// Foreground again → connectSse() mints a new ticket and opens a new EventSource.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
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'));
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ test.describe('Feed — error toast on user action failures', () => {
|
|||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 429,
|
status: 429,
|
||||||
contentType: 'application/json',
|
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.
|
* for a guest who's already viewing the feed.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
test.describe('Host — event lock', () => {
|
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 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 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.
|
// 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);
|
await api.closeEvent(host.jwt);
|
||||||
const evRes = await fetch(`${BASE}/api/v1/host/event`, {
|
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
|
// event (USER_JOURNEYS §9.3, FEATURES capability matrix). Only new uploads are
|
||||||
// rejected. (An earlier revision froze social interaction too; that contradicted
|
// rejected. (An earlier revision froze social interaction too; that contradicted
|
||||||
// the documented behavior and was reverted.)
|
// the documented behavior and was reverted.)
|
||||||
test('a closed event still allows likes and comments, but blocks new uploads', async ({ api, host, guest }) => {
|
test('a closed event still allows likes and comments, but blocks new uploads', async ({
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
api,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
const g = await guest('SocialLocked');
|
const g = await guest('SocialLocked');
|
||||||
|
|
||||||
// Upload while still open so there's a target to interact with.
|
// 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';
|
import { seedUpload, seedComment } from '../../helpers/seed';
|
||||||
|
|
||||||
test.describe('Host — moderation API', () => {
|
test.describe('Host — moderation API', () => {
|
||||||
test('ban with hide_uploads=true sets the right flags', async ({ api, host, guest }) => {
|
test('ban always hides — a banned user is is_banned AND uploads_hidden (USER_JOURNEYS §9.5)', async ({
|
||||||
const target = await guest('Banned1');
|
api,
|
||||||
await api.banUser(host.jwt, target.userId, true);
|
host,
|
||||||
const users = await api.listUsers(host.jwt);
|
guest,
|
||||||
const row = users.find((u: any) => u.id === target.userId);
|
}) => {
|
||||||
expect(row?.is_banned).toBe(true);
|
// Ban is unconditionally a hide: a banned user's content is "gone" everywhere. The endpoint
|
||||||
expect(row?.uploads_hidden).toBe(true);
|
// takes no body and there is no per-request opt-out (the legacy hide_uploads flag is gone).
|
||||||
});
|
|
||||||
|
|
||||||
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.
|
|
||||||
const target = await guest('Banned2');
|
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 users = await api.listUsers(host.jwt);
|
||||||
const row = users.find((u: any) => u.id === target.userId);
|
const row = users.find((u: any) => u.id === target.userId);
|
||||||
expect(row?.is_banned).toBe(true);
|
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 }) => {
|
test('banned user cannot call /upload', async ({ api, host, guest }) => {
|
||||||
const target = await guest('Banned3');
|
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.
|
// 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', {
|
const res = await fetch(
|
||||||
method: 'POST',
|
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload',
|
||||||
headers: { Authorization: `Bearer ${target.jwt}` },
|
{
|
||||||
body: new FormData(),
|
method: 'POST',
|
||||||
});
|
headers: { Authorization: `Bearer ${target.jwt}` },
|
||||||
|
body: new FormData(),
|
||||||
|
}
|
||||||
|
);
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,23 +86,28 @@ test.describe('Host — live role/ban revocation (H1)', () => {
|
|||||||
// Sanity: host token works.
|
// Sanity: host token works.
|
||||||
await api.listUsers(host.jwt);
|
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.
|
// Banned users are rejected with 403 by the auth extractor before any handler runs.
|
||||||
await expect(api.listUsers(host.jwt)).rejects.toThrow(/→ 403/);
|
await expect(api.listUsers(host.jwt)).rejects.toThrow(/→ 403/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a banned host cannot unban themselves', async ({ api, adminToken, host }) => {
|
test('a banned host cannot unban themselves', async ({ api, adminToken, host }) => {
|
||||||
await api.banUser(adminToken, host.userId, false);
|
await api.banUser(adminToken, host.userId);
|
||||||
await expect(
|
await expect(api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] })).rejects.toThrow(
|
||||||
api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] })
|
/→ 403/
|
||||||
).rejects.toThrow(/→ 403/);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// H1 / USER_JOURNEYS §10: a banned user keeps *read* access but every write is
|
// 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},
|
// rejected with 403. The ban is enforced on write handlers + Require{Host,Admin},
|
||||||
// NOT in the base extractor (which would wrongly block reads too).
|
// 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 base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
const target = await guest('BannedRW');
|
const target = await guest('BannedRW');
|
||||||
const auth = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
|
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 ownUpload = await seedUpload(target.jwt, { caption: 'mine' });
|
||||||
const ownComment = await seedComment(target.jwt, ownUpload, 'my comment');
|
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.
|
// Reads still succeed.
|
||||||
const read = await fetch(base + '/api/v1/me/context', { headers: auth(target.jwt) });
|
const read = await fetch(base + '/api/v1/me/context', { headers: auth(target.jwt) });
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { seedUpload } from '../../helpers/seed';
|
import { seedUpload } from '../../helpers/seed';
|
||||||
import { SseListener } from '../../helpers/sse-listener';
|
import { SseListener } from '../../helpers/sse-listener';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Host — live SSE eviction (H3)', () => {
|
test.describe('Host — live SSE eviction (H3)', () => {
|
||||||
test('self-deleting an upload broadcasts upload-deleted', async ({ guest }) => {
|
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);
|
expect(res.status).toBe(204);
|
||||||
|
|
||||||
await sse.waitForEvent(
|
await sse.waitForEvent('upload-deleted', (e) => e.data.upload_id === uploadId);
|
||||||
'upload-deleted',
|
|
||||||
(e) => e.data.upload_id === uploadId
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('banning a user with hide_uploads broadcasts user-hidden', async ({
|
test('banning a user broadcasts user-hidden', async ({ api, host, guest }) => {
|
||||||
api,
|
|
||||||
host,
|
|
||||||
guest,
|
|
||||||
}) => {
|
|
||||||
const target = await guest('HideTarget');
|
const target = await guest('HideTarget');
|
||||||
await seedUpload(target.jwt, { caption: 'should vanish' });
|
await seedUpload(target.jwt, { caption: 'should vanish' });
|
||||||
|
|
||||||
const sse = new SseListener();
|
const sse = new SseListener();
|
||||||
await sse.start(host.jwt);
|
await sse.start(host.jwt);
|
||||||
|
|
||||||
await api.banUser(host.jwt, target.userId, true);
|
await api.banUser(host.jwt, target.userId);
|
||||||
|
|
||||||
await sse.waitForEvent(
|
await sse.waitForEvent('user-hidden', (e) => e.data.user_id === target.userId);
|
||||||
'user-hidden',
|
|
||||||
(e) => e.data.user_id === target.userId
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Frontend regression: the broadcasts above are inert if the client never
|
// 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();
|
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.
|
// 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 });
|
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.
|
* guest can't hit host/admin; host can't hit admin.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Authorization escalation guards', () => {
|
test.describe('Authorization escalation guards', () => {
|
||||||
test('guest → POST /host/event/close returns 403', async ({ guest }) => {
|
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' });
|
await api.patchConfig(adminToken, { max_image_size_mb: '20' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('non-numeric value for a numeric key is rejected', async ({ api, adminToken }) => {
|
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', {
|
const res = await fetch(
|
||||||
method: 'PATCH',
|
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config',
|
||||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
{
|
||||||
body: JSON.stringify({ max_image_size_mb: 'not-a-number' }),
|
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);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unknown config key is rejected (whitelist enforced)', async ({ adminToken }) => {
|
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', {
|
const res = await fetch(
|
||||||
method: 'PATCH',
|
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config',
|
||||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
{
|
||||||
body: JSON.stringify({ totally_fake_key: '1' }),
|
method: 'PATCH',
|
||||||
});
|
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ totally_fake_key: '1' }),
|
||||||
|
}
|
||||||
|
);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,16 +46,23 @@ test.describe('Admin — config API', () => {
|
|||||||
cfg = await api.getConfig(adminToken);
|
cfg = await api.getConfig(adminToken);
|
||||||
expect(cfg.upload_rate_enabled).toBe('false');
|
expect(cfg.upload_rate_enabled).toBe('false');
|
||||||
|
|
||||||
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', {
|
const res = await fetch(
|
||||||
method: 'PATCH',
|
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config',
|
||||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
{
|
||||||
body: JSON.stringify({ upload_rate_enabled: 'maybe' }),
|
method: 'PATCH',
|
||||||
});
|
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ upload_rate_enabled: 'maybe' }),
|
||||||
|
}
|
||||||
|
);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('privacy_note round-trips verbatim, preserving whitespace + newlines', async ({ api, adminToken }) => {
|
test('privacy_note round-trips verbatim, preserving whitespace + newlines', async ({
|
||||||
const note = ' Datenschutz\n • Wir verwenden keine Cookies.\n • Alles bleibt im Browser.\n\n— Dein Host';
|
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 });
|
await api.patchConfig(adminToken, { privacy_note: note });
|
||||||
const cfg = await api.getConfig(adminToken);
|
const cfg = await api.getConfig(adminToken);
|
||||||
expect(cfg.privacy_note).toBe(note);
|
expect(cfg.privacy_note).toBe(note);
|
||||||
@@ -58,7 +71,11 @@ test.describe('Admin — config API', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Admin — stats', () => {
|
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('Stat1');
|
||||||
await guest('Stat2');
|
await guest('Stat2');
|
||||||
await guest('Stat3');
|
await guest('Stat3');
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { seedUpload } from '../../helpers/seed';
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Export — no public leak (CR2)', () => {
|
test.describe('Export — no public leak (CR2)', () => {
|
||||||
test('a real export is downloadable only via the gated endpoint, never via /media', async ({
|
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' });
|
await seedUpload(host.jwt, { caption: 'in the export' });
|
||||||
|
|
||||||
// Host releases the gallery → spawns the real zip/html export jobs.
|
// 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);
|
expect(rel.status).toBe(204);
|
||||||
|
|
||||||
// Wait for the real zip job to finish writing the archive to disk.
|
// 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
|
// …but IS retrievable via the gated single-use ticket endpoint. This proves the
|
||||||
// 404 above means "not public", not merely "no file was produced".
|
// 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 { ticket } = await ticketRes.json();
|
||||||
const dl = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
|
const dl = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
|
||||||
expect(dl.status).toBe(200);
|
expect(dl.status).toBe(200);
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import { test, expect } from '../../fixtures/test';
|
|||||||
import { uploadRaw } from '../../helpers/upload-client';
|
import { uploadRaw } from '../../helpers/upload-client';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Export — video streaming (P4)', () => {
|
test.describe('Export — video streaming (P4)', () => {
|
||||||
test('a real video upload is streamed into Memories.zip (not dropped)', async ({ host }) => {
|
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();
|
const { id } = await up.json();
|
||||||
|
|
||||||
// Release the gallery → spawns the real zip + html export jobs.
|
// 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);
|
expect(rel.status).toBe(204);
|
||||||
|
|
||||||
// Wait for the HTML (Memories.zip) job — that's the one whose media staging P4 changed.
|
// 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');
|
.toBe('done');
|
||||||
|
|
||||||
// Download Memories.zip via the gated single-use ticket.
|
// 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 { ticket } = await ticketRes.json();
|
||||||
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
|
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
|
||||||
expect(dl.status).toBe(200);
|
expect(dl.status).toBe(200);
|
||||||
|
|||||||
@@ -11,21 +11,60 @@ import { ExportPage } from '../../page-objects';
|
|||||||
const SLUG = 'e2e-test-event';
|
const SLUG = 'e2e-test-event';
|
||||||
|
|
||||||
test.describe('Export — release and download', () => {
|
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');
|
const g = await guest('PreRelease');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
const exportPage = new ExportPage(page);
|
const exportPage = new ExportPage(page);
|
||||||
await exportPage.goto();
|
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 }) => {
|
test('export status API reflects released flag', async ({ guest, db }) => {
|
||||||
const g = await guest('ReleaseQuery');
|
const g = await guest('ReleaseQuery');
|
||||||
|
|
||||||
let res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', {
|
let res = await fetch(
|
||||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status',
|
||||||
});
|
{
|
||||||
|
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
let body: any = await res.json();
|
let body: any = await res.json();
|
||||||
expect(body.released).toBe(false);
|
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, 'zip', 'done');
|
||||||
await db.fakeExportJob(SLUG, 'html', 'done');
|
await db.fakeExportJob(SLUG, 'html', 'done');
|
||||||
|
|
||||||
res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', {
|
res = await fetch(
|
||||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status',
|
||||||
});
|
{
|
||||||
|
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
body = await res.json();
|
body = await res.json();
|
||||||
expect(body.released).toBe(true);
|
expect(body.released).toBe(true);
|
||||||
expect(body.zip.status).toBe('done');
|
expect(body.zip.status).toBe('done');
|
||||||
@@ -54,27 +96,32 @@ test.describe('Export — release and download', () => {
|
|||||||
return (await res.json()).ticket;
|
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');
|
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.setExportReleased(SLUG, true);
|
||||||
await db.fakeExportJob(SLUG, 'zip', 'done');
|
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 ticket = await mintTicket(g.jwt);
|
||||||
|
|
||||||
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
|
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);
|
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');
|
const g = await guest('ReadyNoFile');
|
||||||
// Released AND ready, but no Gallery.zip on disk (we never ran a real export) →
|
// Released, and the job is `done` at the LIVE epoch — but no archive was ever written (we
|
||||||
// the handler must 404 on the missing-file check, not 200/500 or serve a stale file.
|
// 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.setExportReleased(SLUG, true);
|
||||||
await db.setExportZipReady(SLUG, true);
|
|
||||||
await db.fakeExportJob(SLUG, 'zip', 'done');
|
await db.fakeExportJob(SLUG, 'zip', 'done');
|
||||||
|
await db.setExportZipReady(SLUG, true);
|
||||||
const ticket = await mintTicket(g.jwt);
|
const ticket = await mintTicket(g.jwt);
|
||||||
|
|
||||||
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
|
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.
|
* tampered signature, expired sessions, wrong role.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { ADMIN_PASSWORD } from '../../fixtures/api-client';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
/** RFC-4648 base64url with no padding. */
|
/** RFC-4648 base64url with no padding. */
|
||||||
function b64u(s: string) {
|
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.describe('Adversarial — JWT', () => {
|
||||||
test('alg:none token claiming admin role is rejected', async () => {
|
test('alg:none token claiming admin role is rejected', async () => {
|
||||||
const header = b64u(JSON.stringify({ alg: 'none', typ: 'JWT' }));
|
const header = b64u(JSON.stringify({ alg: 'none', typ: 'JWT' }));
|
||||||
const payload = b64u(JSON.stringify({
|
const payload = b64u(
|
||||||
sub: '00000000-0000-0000-0000-000000000000',
|
JSON.stringify({
|
||||||
role: 'admin',
|
sub: '00000000-0000-0000-0000-000000000000',
|
||||||
event_id: '00000000-0000-0000-0000-000000000000',
|
role: 'admin',
|
||||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
event_id: '00000000-0000-0000-0000-000000000000',
|
||||||
}));
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
})
|
||||||
|
);
|
||||||
const token = `${header}.${payload}.`;
|
const token = `${header}.${payload}.`;
|
||||||
const res = await fetch(`${BASE}/api/v1/admin/config`, {
|
const res = await fetch(`${BASE}/api/v1/admin/config`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
@@ -43,7 +49,9 @@ test.describe('Adversarial — JWT', () => {
|
|||||||
expect(res.status).toBe(401);
|
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 g = await guest('RolePromote');
|
||||||
const parts = g.jwt.split('.');
|
const parts = g.jwt.split('.');
|
||||||
const original = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
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);
|
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 g = await guest('BruteParallel');
|
||||||
const wrong = g.pin === '0000' ? '1111' : '0000';
|
const wrong = g.pin === '0000' ? '1111' : '0000';
|
||||||
|
|
||||||
@@ -124,30 +134,131 @@ test.describe('Adversarial — PIN brute-force', () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
const statuses = attempts.map((r) => r.status);
|
const statuses = attempts.map((r) => r.status);
|
||||||
expect(statuses.filter((s) => s === 200)).toHaveLength(0);
|
expect(
|
||||||
// Documented behavior: lockout counter may race so not every status is 429.
|
statuses.filter((s) => s === 200),
|
||||||
// Critical invariant: no attempt succeeded.
|
'a wrong PIN must never authenticate'
|
||||||
if (!statuses.some((s) => s === 429)) {
|
).toHaveLength(0);
|
||||||
console.warn('[finding] PIN-attempt counter races under parallel requests — none hit lockout.');
|
|
||||||
}
|
// 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.describe('Adversarial — admin password brute-force', () => {
|
||||||
test('repeated wrong passwords do NOT lock the admin (documented finding)', async () => {
|
// These tests deliberately exhaust the admin-login limiter for the shared test IP. That creates a
|
||||||
// The admin login handler does not currently implement lockout. This test
|
// chicken-and-egg for the NEXT test: its truncate auto-fixture must itself call admin_login before
|
||||||
// documents the behavior so any future change is intentional.
|
// it can reset the counter — so a still-full window would lock the fixture out with 429 before it
|
||||||
const attempts = await Promise.all(
|
// could clear anything. Disable the toggle here via patchConfig (which authenticates with a JWT,
|
||||||
Array.from({ length: 10 }, () =>
|
// NOT the rate-limited admin_login path), so the next login is clear; truncate then wipes the map.
|
||||||
fetch(`${BASE}/api/v1/admin/login`, {
|
// Runs even if the test body failed, so a failure can't poison the rest of the run.
|
||||||
method: 'POST',
|
test.afterEach(async ({ api, adminToken }) => {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
await api.patchConfig(adminToken, { admin_login_rate_enabled: 'false' });
|
||||||
body: JSON.stringify({ password: 'wrong-' + Math.random() }),
|
});
|
||||||
})
|
|
||||||
)
|
const tryLogin = (password: string) =>
|
||||||
);
|
fetch(`${BASE}/api/v1/admin/login`, {
|
||||||
const statuses = attempts.map((r) => r.status);
|
method: 'POST',
|
||||||
expect(statuses.every((s) => s === 401)).toBe(true);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
console.warn('[finding] /admin/login has no rate-limit or lockout — bcrypt cost is the only defense.');
|
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 { test, expect } from '../../fixtures/test';
|
||||||
import { seedUpload, seedComment, listComments, findFeedRow } from '../../helpers/seed';
|
import { seedUpload, seedComment, listComments, findFeedRow } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Adversarial — deep authorization', () => {
|
test.describe('Adversarial — deep authorization', () => {
|
||||||
// IDOR: user B must not be able to delete user A's REAL comment. This exercises the
|
// 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
|
// 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
|
// at the all-zeros UUID, which 404s at the lookup BEFORE that guard runs, so it never
|
||||||
// tested authorization at all.
|
// 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 a = await guest('CommentOwnerA');
|
||||||
const b = await guest('AttackerB');
|
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.
|
// 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 a = await guest('UploadOwnerA');
|
||||||
const b = await guest('AttackerB2');
|
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.
|
// 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 a = await guest('UploadOwnerA2');
|
||||||
const b = await guest('AttackerB3');
|
const b = await guest('AttackerB3');
|
||||||
|
|
||||||
@@ -75,37 +76,55 @@ test.describe('Adversarial — deep authorization', () => {
|
|||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
|
|
||||||
// No state change: the caption A set is intact.
|
// 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);
|
const row = findFeedRow(await feedRes.json(), uploadId);
|
||||||
expect(row?.caption).toBe('original caption');
|
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 }) => {
|
test('banned user cannot toggle a like', async ({ api, host, guest }) => {
|
||||||
const target = await guest('BannedLike');
|
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',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${target.jwt}` },
|
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 }) => {
|
test('banned user cannot post a comment', async ({ api, host, guest }) => {
|
||||||
const target = await guest('BannedComment');
|
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',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${target.jwt}`, 'Content-Type': 'application/json' },
|
headers: { Authorization: `Bearer ${target.jwt}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ body: 'should be rejected' }),
|
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');
|
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`, {
|
const res = await fetch(`${BASE}/api/v1/feed`, {
|
||||||
headers: { Authorization: `Bearer ${target.jwt}` },
|
headers: { Authorization: `Bearer ${target.jwt}` },
|
||||||
@@ -114,7 +133,11 @@ test.describe('Adversarial — deep authorization', () => {
|
|||||||
expect(res.status).toBe(200);
|
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');
|
const otherHost = await guest('OtherHost');
|
||||||
// Promote so they have a host JWT to play with.
|
// Promote so they have a host JWT to play with.
|
||||||
await api.setRole(host.jwt, otherHost.userId, 'host');
|
await api.setRole(host.jwt, otherHost.userId, 'host');
|
||||||
@@ -130,15 +153,40 @@ test.describe('Adversarial — deep authorization', () => {
|
|||||||
expect(stillWorks.status).toBe(200);
|
expect(stillWorks.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('promote endpoint cannot be used to make oneself admin', async ({ host }) => {
|
// Privilege escalation, tested against REAL targets.
|
||||||
const res = await fetch(`${BASE}/api/v1/host/users/${'00000000-0000-0000-0000-000000000000'}/role`, {
|
//
|
||||||
|
// 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',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${host.jwt}`, 'Content-Type': 'application/json' },
|
headers: { Authorization: `Bearer ${host.jwt}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ role: 'admin' }),
|
body: JSON.stringify({ role: 'admin' }),
|
||||||
});
|
});
|
||||||
// 400 (invalid role for host-callable endpoint) or 403/404.
|
expect(res.status).not.toBe(204);
|
||||||
expect([400, 403, 404]).toContain(res.status);
|
expect(res.status).not.toBe(200);
|
||||||
// Critically, NOT 200/204.
|
|
||||||
expect([200, 204]).not.toContain(res.status);
|
// 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 { test, expect } from '../../fixtures/test';
|
||||||
import { mintSseTicket } from '../../helpers/sse';
|
import { mintSseTicket } from '../../helpers/sse';
|
||||||
|
import { seedUpload } from '../../helpers/seed';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
test.describe('Adversarial — small-scale abuse', () => {
|
test.describe('Adversarial — small-scale abuse', () => {
|
||||||
// Note: the truncate auto-fixture resets every rate-limit toggle back to false
|
// 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);
|
expect(statuses.some((s) => s === 201 || s === 409)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('10 MB comment body is rejected (multipart-less endpoint)', async ({ guest }) => {
|
// The comment body cap lives in [backend/src/handlers/social.rs] `add_comment`:
|
||||||
const g = await guest('BigComment');
|
// if text_chars == 0 || text_chars > 500 → 400
|
||||||
const huge = 'A'.repeat(10 * 1024 * 1024);
|
// It must be exercised against a REAL upload: the handler looks the upload up (and
|
||||||
const res = await fetch(`${BASE}/api/v1/upload/00000000-0000-0000-0000-000000000000/comments`, {
|
// 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',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' },
|
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ body: huge }),
|
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);
|
test('comment body over the 500-char cap is rejected with 400', async ({ guest }) => {
|
||||||
// Not 200 — that would mean we accepted a 10 MB comment.
|
const g = await guest('LongComment');
|
||||||
expect(res.status).not.toBe(200);
|
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 }) => {
|
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 controllers = tickets.map(() => new AbortController());
|
||||||
const requests = tickets.map((ticket, i) =>
|
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);
|
const responses = await Promise.all(requests);
|
||||||
// All accepted (or some rate-limited — both fine).
|
// All accepted (or some rate-limited — both fine).
|
||||||
|
|||||||
Binary file not shown.
@@ -6,8 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { seedUpload } from '../../helpers/seed';
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Media gating — moderation revokes preview access (F2)', () => {
|
test.describe('Media gating — moderation revokes preview access (F2)', () => {
|
||||||
test('preview served via gated alias, blocked directly, and 404 after delete', async ({
|
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`);
|
const afterDelete = await fetch(`${BASE}/api/v1/upload/${id}/preview`);
|
||||||
expect(afterDelete.status, 'moderation must revoke preview access').toBe(404);
|
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.
|
* still work.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
function patchRole(token: string, userId: string, role: string) {
|
function patchRole(token: string, userId: string, role: string) {
|
||||||
return fetch(`${BASE}/api/v1/host/users/${userId}/role`, {
|
return fetch(`${BASE}/api/v1/host/users/${userId}/role`, {
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { mintSseTicket, openStream } from '../../helpers/sse';
|
import { mintSseTicket, openStream } from '../../helpers/sse';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Adversarial — SSE ticket abuse', () => {
|
test.describe('Adversarial — SSE ticket abuse', () => {
|
||||||
test('minting a ticket requires authentication', async () => {
|
test('minting a ticket requires authentication', async () => {
|
||||||
|
|||||||
@@ -14,39 +14,56 @@ test.describe('Adversarial — UI render escape', () => {
|
|||||||
const r = await api.join(payload);
|
const r = await api.join(payload);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.evaluate(({ j, u, n, p }) => {
|
await page.evaluate(
|
||||||
localStorage.setItem('eventsnap_jwt', j);
|
({ j, u, n, p }) => {
|
||||||
localStorage.setItem('eventsnap_user_id', u);
|
localStorage.setItem('eventsnap_jwt', j);
|
||||||
localStorage.setItem('eventsnap_display_name', n);
|
localStorage.setItem('eventsnap_user_id', u);
|
||||||
localStorage.setItem('eventsnap_pin', p);
|
localStorage.setItem('eventsnap_display_name', n);
|
||||||
}, { j: r.jwt, u: r.user_id, n: payload, p: r.pin });
|
localStorage.setItem('eventsnap_pin', p);
|
||||||
|
},
|
||||||
|
{ j: r.jwt, u: r.user_id, n: payload, p: r.pin }
|
||||||
|
);
|
||||||
|
|
||||||
page.on('dialog', (d) => {
|
page.on('dialog', (d) => {
|
||||||
throw new Error(`Dialog fired: ${d.message()}`);
|
throw new Error(`Dialog fired: ${d.message()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/account');
|
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);
|
const fired = await page.evaluate(() => (window as any).__xssFired === true);
|
||||||
expect(fired).toBe(false);
|
expect(fired).toBe(false);
|
||||||
|
|
||||||
// <b> tag inside the name should also not render as bold — Svelte escapes the entire string.
|
// <b> tag inside the name should also not render as bold — Svelte escapes the entire
|
||||||
const boldCount = await page.locator('b:has-text("BOLD")').count();
|
// string. toHaveCount auto-retries, so this can't win by racing hydration.
|
||||||
expect(boldCount).toBe(0);
|
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 payload = `'); DROP TABLE users; --`;
|
||||||
const r = await api.join(payload);
|
const r = await api.join(payload);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.evaluate(({ j, u, n, p }) => {
|
await page.evaluate(
|
||||||
localStorage.setItem('eventsnap_jwt', j);
|
({ j, u, n, p }) => {
|
||||||
localStorage.setItem('eventsnap_user_id', u);
|
localStorage.setItem('eventsnap_jwt', j);
|
||||||
localStorage.setItem('eventsnap_display_name', n);
|
localStorage.setItem('eventsnap_user_id', u);
|
||||||
localStorage.setItem('eventsnap_pin', p);
|
localStorage.setItem('eventsnap_display_name', n);
|
||||||
}, { j: r.jwt, u: r.user_id, n: payload, p: r.pin });
|
localStorage.setItem('eventsnap_pin', p);
|
||||||
|
},
|
||||||
|
{ j: r.jwt, u: r.user_id, n: payload, p: r.pin }
|
||||||
|
);
|
||||||
|
|
||||||
await page.goto('/account');
|
await page.goto('/account');
|
||||||
// Page renders.
|
// Page renders.
|
||||||
|
|||||||
@@ -13,18 +13,34 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { seedUpload, seedComment } from '../../helpers/seed';
|
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 = [
|
const XSS_PAYLOADS = [
|
||||||
`<script>window.__xssFired=true</script>`,
|
`<script>window.__x=1</script>`, // 29
|
||||||
`<img src=x onerror="window.__xssFired=true">`,
|
`<img src=x onerror="window.__x=1">`, // 34
|
||||||
`"><svg onload="window.__xssFired=true">`,
|
`"><svg onload="window.__x=1">`, // 29
|
||||||
`<iframe src="javascript:window.parent.__xssFired=true"></iframe>`,
|
`<iframe src="javascript:parent.__x=1"></iframe>`, // 47
|
||||||
`javascript:window.__xssFired=true`,
|
`javascript:window.__x=1`, // 23
|
||||||
`<a href="javascript:window.__xssFired=true">click</a>`,
|
`<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 = [
|
const SQLI_PAYLOADS = [
|
||||||
`'; DROP TABLE "user"; --`,
|
`'; DROP TABLE "user"; --`,
|
||||||
`' OR 1=1 --`,
|
`' OR 1=1 --`,
|
||||||
@@ -34,20 +50,14 @@ const SQLI_PAYLOADS = [
|
|||||||
|
|
||||||
test.describe('Adversarial — input injection (display name)', () => {
|
test.describe('Adversarial — input injection (display name)', () => {
|
||||||
for (const payload of XSS_PAYLOADS) {
|
for (const payload of XSS_PAYLOADS) {
|
||||||
test(`name with XSS payload ${JSON.stringify(payload).slice(0, 40)} never executes`, async ({ api, page }) => {
|
test(`name with XSS payload ${JSON.stringify(payload).slice(0, 40)} never executes`, async ({
|
||||||
// Payloads > 50 chars are rejected by the join handler — that's a valid defense.
|
api,
|
||||||
// Only if the API accepts the payload do we proceed to assert it never executes
|
page,
|
||||||
// when rendered.
|
}) => {
|
||||||
let res;
|
// No try/catch escape hatch: every payload is short enough to be accepted, so a
|
||||||
try {
|
// rejection here is a real failure (the payload would never be rendered, and the
|
||||||
res = await api.join(payload);
|
// "nothing executed" assertions below would be vacuous).
|
||||||
} catch (e: any) {
|
const res = await api.join(payload);
|
||||||
if (/→ 400/.test(e.message ?? '')) {
|
|
||||||
// Defended at the API. No need to render.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
expect(res.jwt).toBeTruthy();
|
expect(res.jwt).toBeTruthy();
|
||||||
|
|
||||||
// Render the name in the account page by signing in.
|
// 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,
|
// 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.
|
// 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);
|
const fired = await page.evaluate(() => (window as any).__x === 1);
|
||||||
expect(fired, 'window.__xssFired should never be set').toBe(false);
|
expect(fired, 'window.__x should never be set').toBe(false);
|
||||||
expect(dialogs, 'no dialogs should appear').toHaveLength(0);
|
expect(dialogs, 'no dialogs should appear').toHaveLength(0);
|
||||||
|
|
||||||
// Inline script tag in the displayed name should be rendered as text, not parsed.
|
// 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);
|
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)', () => {
|
test.describe('Adversarial — stored XSS (caption)', () => {
|
||||||
for (const payload of XSS_PAYLOADS) {
|
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');
|
const g = await guest('CapXss');
|
||||||
// A trailing marker lets us wait until the caption has actually rendered before
|
// 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.
|
// 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}$/);
|
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
|
||||||
const dialogs: string[] = [];
|
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);
|
await signIn(page, g);
|
||||||
// Wait for the caption text to land in the DOM (escaped, as literal text).
|
// 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);
|
expect(dialogs, 'no dialogs from a caption').toHaveLength(0);
|
||||||
// The payload must be inert text, not a live element / script.
|
// 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('img[onerror]').count(), 'no live onerror img from caption').toBe(
|
||||||
expect(await page.locator('script:has-text("__xssFired")').count(), 'no executable script from caption').toBe(0);
|
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)', () => {
|
test.describe('Adversarial — stored XSS (comment)', () => {
|
||||||
// The two payloads that actually execute on render (script injection via innerHTML
|
// 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.
|
// does not) — enough to prove the comment body is escaped without a slow 6× lightbox loop.
|
||||||
const COMMENT_PAYLOADS = [
|
const COMMENT_PAYLOADS = [`<img src=x onerror="window.__x=1">`, `"><svg onload="window.__x=1">`];
|
||||||
`<img src=x onerror="window.__xssFired=true">`,
|
|
||||||
`"><svg onload="window.__xssFired=true">`,
|
|
||||||
];
|
|
||||||
for (const payload of COMMENT_PAYLOADS) {
|
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 author = await guest('CmtXss');
|
||||||
const id = await seedUpload(author.jwt, { caption: 'pic CAPMARK' });
|
const id = await seedUpload(author.jwt, { caption: 'pic CAPMARK' });
|
||||||
// Post the XSS comment via the API (verbatim storage).
|
// Post the XSS comment via the API (verbatim storage).
|
||||||
await seedComment(author.jwt, id, `${payload} CMTMARK`);
|
await seedComment(author.jwt, id, `${payload} CMTMARK`);
|
||||||
|
|
||||||
const dialogs: string[] = [];
|
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);
|
await signIn(page, author);
|
||||||
// Open the lightbox (which loads + renders comments).
|
// 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' });
|
.getByRole('button', { name: 'Bild vergrößern' });
|
||||||
await expect(imageButton).toBeVisible({ timeout: 10_000 });
|
await expect(imageButton).toBeVisible({ timeout: 10_000 });
|
||||||
await imageButton.click();
|
await imageButton.click();
|
||||||
@@ -137,18 +173,28 @@ test.describe('Adversarial — stored XSS (comment)', () => {
|
|||||||
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
|
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
|
||||||
await expect(lightbox).toBeVisible();
|
await expect(lightbox).toBeVisible();
|
||||||
// Wait until the comment (marker) has rendered.
|
// 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(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)', () => {
|
test.describe('Adversarial — input injection (SQL-injection patterns)', () => {
|
||||||
for (const payload of SQLI_PAYLOADS) {
|
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);
|
const res = await api.join(payload);
|
||||||
expect(res.jwt).toBeTruthy();
|
expect(res.jwt).toBeTruthy();
|
||||||
|
|
||||||
@@ -191,7 +237,10 @@ test.describe('Adversarial — input length & encoding', () => {
|
|||||||
expect([400, 201, 409]).toContain(res.status);
|
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 rtlName = `AliceeciVlA`; // U+202E RIGHT-TO-LEFT OVERRIDE
|
||||||
const r = await api.join(rtlName);
|
const r = await api.join(rtlName);
|
||||||
expect(r.user_id).toMatch(/^[0-9a-f-]{36}$/);
|
expect(r.user_id).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ test.describe('Browser chaos — environment', () => {
|
|||||||
await ctx.close();
|
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');
|
const g = await guest('QuotaFull');
|
||||||
|
|
||||||
// Pre-fill localStorage with junk to push us near the quota.
|
// 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);
|
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');
|
const g = await guest('HostileCss');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
@@ -59,7 +67,11 @@ test.describe('Browser chaos — environment', () => {
|
|||||||
await expect(page).toHaveURL(/\/account$/);
|
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');
|
const g = await guest('ClockSkew');
|
||||||
|
|
||||||
// Override Date.now() to be 1h in the future BEFORE the JWT check.
|
// 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();
|
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');
|
const g = await guest('ClockBack');
|
||||||
|
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
|
||||||
test.describe('Browser chaos — IndexedDB', () => {
|
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');
|
const g = await guest('IdbPurge');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
@@ -16,11 +20,12 @@ test.describe('Browser chaos — IndexedDB', () => {
|
|||||||
// Drop every IndexedDB database the app might use.
|
// Drop every IndexedDB database the app might use.
|
||||||
const dbs = (await (indexedDB as any).databases?.()) ?? [];
|
const dbs = (await (indexedDB as any).databases?.()) ?? [];
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
dbs.map(({ name }: { name: string }) =>
|
dbs.map(
|
||||||
new Promise<void>((resolve) => {
|
({ name }: { name: string }) =>
|
||||||
const req = indexedDB.deleteDatabase(name);
|
new Promise<void>((resolve) => {
|
||||||
req.onsuccess = req.onerror = req.onblocked = () => 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