diff --git a/e2e/specs/06-export/download-retired-epoch.spec.ts b/e2e/specs/06-export/download-retired-epoch.spec.ts new file mode 100644 index 0000000..ed66c1e --- /dev/null +++ b/e2e/specs/06-export/download-retired-epoch.spec.ts @@ -0,0 +1,73 @@ +/** + * A ticket minted against a LIVE archive must stop working the moment that archive is retired. + * + * This is the download-side half of the stale-keepsake guarantee, and it was left unasserted. Both + * tests in `export.spec.ts` are named "ZIP download 404s…" but now assert only that the *mint* + * refuses — correctly, since `/export/ticket` pre-validates and refusing there spends none of the + * guest's three daily downloads. The consequence is that nothing exercised `resolve_export_file` + * on the download path itself, so removing that check would not have turned anything red. + * + * It cannot be tested by minting against an already-dead archive (the mint refuses first), so the + * order has to be: real release → mint while healthy → retire → download. Which is also exactly + * what a host taking a photo down mid-download does to a ticket already in flight. + */ +import { test, expect } from '../../fixtures/test'; +import { seedUpload } from '../../helpers/seed'; +import { BASE } from '../../helpers/env'; + +const SLUG = 'e2e-test-event'; + +test.describe('Export — a retired generation cannot be downloaded', () => { + test('a ticket minted before the epoch moved is refused at the download, not served stale', async ({ + host, + db, + }) => { + test.setTimeout(60_000); + const bearer = { Authorization: `Bearer ${host.jwt}` }; + + await seedUpload(host.jwt, { caption: 'about to go stale' }); + + const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { + method: 'POST', + headers: bearer, + }); + expect(rel.status).toBe(204); + + await expect + .poll( + async () => { + const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer }); + return (await res.json()).zip?.status; + }, + { timeout: 45_000, intervals: [500] } + ) + .toBe('done'); + + // Mint while everything is healthy. This must succeed, or the assertion below proves nothing — + // a 404 on a ticket that was never valid would be green for the wrong reason. + const mint = await fetch(`${BASE}/api/v1/export/ticket?kind=zip`, { + method: 'POST', + headers: bearer, + }); + expect(mint.status, 'the ticket must be mintable while the archive is live').toBe(200); + const { ticket } = await mint.json(); + expect(ticket).toBeTruthy(); + + // And it genuinely works right now — the positive control for the negative below. + const before = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`); + expect(before.status, 'the ticket must serve the archive while it is current').toBe(200); + + // Now retire the generation, which is what a reopen or a post-release takedown does. + await db.setExportZipReady(SLUG, false); + + // The SAME ticket — still unexpired, still under its redemption cap, session still valid — + // must now be refused. Readiness is derived at READ time from `job.epoch = event.export_epoch`, + // so this is the check that stops a superseded archive being served to a guest who happened to + // hold a ticket when the host moderated. + const after = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`); + expect( + after.status, + 'a retired archive must 404 on the download path, not be served from a still-valid ticket' + ).toBe(404); + }); +});