docs(backup): make the backup commands work; fix the e2e/prod divergences

Backup. Both documented commands failed on the shipped stack, and the sentence
explaining them was wrong too:

- `pg_dump $DATABASE_URL` — `DATABASE_URL` is only ever in the compose
  environment, never an operator's shell, and it points at `db:5432`, which is
  compose-internal DNS. The app image has no postgres client either.
- `> /media/backups/…` — `/media` is a named volume mounted inside the app
  container, not a host path, and nothing ever creates a `backups` subdirectory.
- `rsync /opt/eventsnap/media/` — that path does not exist anywhere.
- "a single path to back up" — false, and dangerously so: exports were moved to
  their own `exports_data` volume precisely so a keepsake (which contains every
  photo in the event) can't be served off the media tree. Backing up only
  `media_data` silently loses every generated keepsake.

Rewritten as three commands — db via `docker compose exec -T db pg_dump`, and one
`docker run … tar` per volume — all verified against the running stack. The
volume mounts use `/src`, not `/media`: I hit the footgun while testing this.
Docker pre-populates an EMPTY volume from the image's own directory and chowns it
to match, so `-v media_data:/media alpine` tars alpine's cdrom/floppy/usb, writes
them into the volume, and leaves it root-owned so the non-root app can no longer
write. Mounting where the image has nothing avoids all of it. Documented inline
so the next person doesn't rediscover it.

Also correct the architecture notes: `/media/*` no longer routes to the backend
(that static tree was removed as a gating bypass), and `exports_data` was missing
from the volume list — the one volume an operator most needs to know about.

e2e stack: add the `EXPORT_PATH` + `/exports` volume it was missing. The file
says "mirrors production layout"; without these, exports landed on the container's
writable layer at the default path, so export-leak and export-video wrote real
archives into ephemeral storage and the "exports live outside media" invariant
was never actually exercised.

Pre-existing red test, unrelated to the audit: all four 02-upload/quota tests
have been failing since 4464147 "stop /me/quota leaking raw disk to guests"
(2026-07-19), which post-dates the spec's last edit. `setLimitTo` calibrated
`quota_tolerance` from `free_disk_bytes` read through the GUEST's token — a field
that commit deliberately zeroes for non-staff. Dividing by it yields a NaN
tolerance, so every test in the block died in the helper. Read the calibration
inputs through a staff token and keep reading the ceiling back through the guest,
whose limit is the thing under test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-28 07:49:52 +02:00
parent c4e9b89af0
commit 27e4004cc8
4 changed files with 81 additions and 21 deletions

View File

@@ -56,14 +56,21 @@ function upload(jwt: string, name: string) {
/**
* 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.
*
* `staffJwt` reads the calibration inputs, `jwt` is the guest the limit is being aimed at.
* They must be different tokens: `free_disk_bytes` and `active_uploaders` are raw server
* telemetry and `/me/quota` zeroes both for non-staff (handlers/me.rs — "must never reach a
* guest"). Calibrating off the guest's own response divides by zero and yields a NaN
* tolerance, which is what silently broke this whole describe block.
*/
async function setLimitTo(
api: any,
adminToken: string,
staffJwt: string,
jwt: string,
targetBytes: number
): Promise<number> {
const q = await quotaOf(jwt);
const q = await quotaOf(staffJwt);
expect(
q.free_disk_bytes,
'the disk must be readable, else quota fails OPEN and proves nothing'
@@ -72,6 +79,7 @@ async function setLimitTo(
const tolerance = (targetBytes * active) / (q.free_disk_bytes as number);
await api.patchConfig(adminToken, { quota_tolerance: tolerance.toExponential(12) });
// Read back through the GUEST, whose ceiling is the one under test.
const after = await quotaOf(jwt);
expect(after.enabled).toBe(true);
return after.limit_bytes as number;
@@ -89,10 +97,11 @@ test.describe('Upload — storage quota enforcement', () => {
api,
adminToken,
guest,
host,
}) => {
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));
const limit = await setLimitTo(api, adminToken, host.jwt, g.jwt, Math.floor(SIZE / 2));
expect(limit).toBeLessThan(SIZE);
const res = await upload(g.jwt, 'too-big.jpg');
@@ -111,9 +120,10 @@ test.describe('Upload — storage quota enforcement', () => {
api,
adminToken,
guest,
host,
}) => {
const g = await guest('QuotaUnder');
await setLimitTo(api, adminToken, g.jwt, SIZE * 4);
await setLimitTo(api, adminToken, host.jwt, g.jwt, SIZE * 4);
expect((await upload(g.jwt, 'fine.jpg')).status).toBe(201);
expect((await quotaOf(g.jwt)).used_bytes).toBe(SIZE);
@@ -123,11 +133,12 @@ test.describe('Upload — storage quota enforcement', () => {
api,
adminToken,
guest,
host,
}) => {
const g = await guest('QuotaRacer');
// Room for exactly ONE file.
const limit = await setLimitTo(api, adminToken, g.jwt, Math.floor(SIZE * 1.5));
const limit = await setLimitTo(api, adminToken, host.jwt, g.jwt, Math.floor(SIZE * 1.5));
expect(limit).toBeGreaterThanOrEqual(SIZE);
expect(limit).toBeLessThan(SIZE * 2);
@@ -170,10 +181,11 @@ test.describe('Upload — storage quota enforcement', () => {
api,
adminToken,
guest,
host,
}) => {
// 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);
await setLimitTo(api, adminToken, host.jwt, g.jwt, SIZE * 10);
const before = await quotaOf(g.jwt);
expect(before.enabled).toBe(true);