chore(deploy): expose the export rebuild hatch in the UI; rehearse migration 014

Three deploy-readiness gaps, none of them in the export invariant itself.

1. The escape hatch was unreachable. `POST /host/export/rebuild` was added as the fix
   for "a failed export is terminal", but nothing in the frontend called it — so recovery
   required a terminal, the API docs and a valid JWT. The host page instead told the host
   to reopen uploads and re-release, which is the destructive act the endpoint exists to
   avoid (it unlocks the gallery to every guest and retracts the release). The failed
   state now offers "Erneut versuchen"; a ready keepsake can be rebuilt behind a confirm,
   since a rebuild makes it briefly undownloadable.

2. Migration 014 had never been run against anything. It is the repo's first destructive
   migration and its backfill has to carry the old notion of "downloadable" across exactly
   — get it wrong and a released event's keepsake silently 404s, on data that cannot be
   re-created. backend/scripts/rehearse-014.sh proves it on a throwaway postgres, against
   a real pg_dump or a synthetic DB covering every pre-014 state, asserting up, down and
   up-again all preserve downloadability. Verified non-vacuous: retiring nothing (ELSE -1
   -> ELSE e.export_epoch) fails it, naming the three keepsakes it would resurrect.

   It also surfaced that the down migration is an inverse UP TO DRIFT: a ready flag that
   disagreed with its job row comes back FALSE. That heals corruption rather than
   restoring it, and costs nothing (no file_path to serve), so the assertion checks what
   actually matters — no genuinely downloadable keepsake loses its flag — and reports the
   normalisation instead of failing on it.

3. No advisory scanning. cargo audit + npm audit, in .github/workflows/ because Gitea
   Actions scans it too and e2e.yml already resolves actions/* from GitHub. The runner is
   not assumed to have cargo. RUSTSEC-2023-0071 (rsa/Marvin) is ignored SPECIFICALLY, not
   globally: it is unreachable here (HS256 + bcrypt, Postgres only) and unpatched, so
   failing on it would mean a permanently red pipeline everyone learns to ignore.

Tests: e2e pins that a failed keepsake recovers via rebuild while the event stays
released and uploads stay locked — so a future refactor implementing rebuild as an
internal reopen+re-release fails — and that rebuild cannot publish an unreleased gallery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-14 21:09:53 +02:00
parent 0447a6ad0e
commit 3c683247c0
4 changed files with 380 additions and 2 deletions

View File

@@ -446,4 +446,69 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
sse.stop();
});
test('a FAILED keepsake is recoverable via rebuild — without reopening uploads to guests', async ({
host,
}) => {
// The escape hatch. A failed export used to be TERMINAL at runtime: `release_gallery` 409s on an
// already-released event, `recover_exports` only runs at boot, and nothing else retried. The
// host's only outs were restarting the container or REOPENING the event — which unlocks uploads
// to every guest and retracts the release, i.e. the cure was worse than the disease. (The host
// page literally used to instruct exactly that.)
//
// What this pins is not just "rebuild works", but that recovery is NON-DESTRUCTIVE: the event
// stays released and uploads stay locked across it. If a future refactor implements rebuild as
// an internal reopen+re-release, those two assertions fail.
await seedUpload(host.jwt, { caption: 'keepsake' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
// Simulate the worker having died: force the job terminal-failed, the state the host is stuck in.
await pgQuery(
`UPDATE export_job ej SET status = 'failed', file_path = NULL, progress_pct = 40
FROM event e WHERE e.id = ej.event_id AND e.slug = '${SLUG}' AND ej.type = 'zip'`
);
const failed = await exportStatus(host.jwt);
expect(failed.released).toBe(true);
expect(failed.zip.status).toBe('failed');
// Stuck: the keepsake is not downloadable and no amount of re-releasing helps.
const ticket = await mintTicket(host.jwt);
expect((await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket))).status).toBe(404);
// ("Galerie wurde bereits freigegeben." — this is the dead end the rebuild endpoint exists for.)
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(400);
const epochBefore = await eventEpoch();
// The escape hatch.
expect((await post('/api/v1/host/export/rebuild', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
// Recovered — and the keepsake is real, not an empty shell.
expect(await downloadZipEntries(host.jwt)).not.toHaveLength(0);
// ...and it cost the guests nothing: still released, still locked.
const [ev] = await pgQuery<{ released: boolean; locked: boolean }>(
`SELECT export_released_at IS NOT NULL AS released, uploads_locked_at IS NOT NULL AS locked
FROM event WHERE slug = '${SLUG}'`
);
expect(ev.released).toBe(true);
expect(ev.locked).toBe(true);
// A rebuild is a new generation, so any worker still alive from the failed one is inert.
expect(await eventEpoch()).toBeGreaterThan(epochBefore);
expect(await zipJobEpoch()).toBe(await eventEpoch());
});
test('rebuild is rejected when the gallery was never released', async ({ host }) => {
// Nothing to rebuild — and, more to the point, rebuild must not become a back door that
// publishes a keepsake for an event the host never released.
const res = await post('/api/v1/host/export/rebuild', host.jwt);
expect(res.status).toBe(400);
const [ev] = await pgQuery<{ released: boolean }>(
`SELECT export_released_at IS NOT NULL AS released FROM event WHERE slug = '${SLUG}'`
);
expect(ev.released).toBe(false);
});
});