Files
EventSnap/e2e/specs/06-export/export.spec.ts
fabi 32dfe6874a test(e2e): make nine red specs assert the contracts the code actually implements
The e2e suite had never been run during this audit. It failed 9 of 256; seven of those
predated the audit's changes, established by building a stack from a clean HEAD worktree
and running the same specs against it rather than guessing.

Most were stale assertions rather than product defects:

- quota.spec solved for a target limit using the observed uploader count, but the divisor is
  max(active, estimated_guest_count, 1) and that config seeds at 100 — so every limit it
  aimed for came out 100x small and every "within quota" upload 413'd.
- rate-limit-shared-nat destructured `ticket` from a 429 body and fetched with
  `ticket=undefined`, turning the 429 under test into an unrelated 401. It also faked a
  release with no archive on disk, so the mint's pre-check 404'd and the per-day limiter was
  never reached; it now does a real release and asserts 200 rather than "not 429".
- ddos allowed only [200,429] from ten concurrent streams, so it failed on the very defence
  it exercises: four tickets per session survive and the rest correctly 401. Now asserts
  exactly four, which a tightened cap or an inverted eviction order would catch.
- auth-tampering asserted a throttled IP is refused EVEN with the correct password. That
  contract was deliberately removed — it let any phone on the venue NAT lock the operator
  out of their own admin panel, with a circular escape hatch. Inverted, plus a new check
  that a success does not refill an attacker's bucket.
- moderation-ui assumed a ban leaves a comment "stuck on screen"; `list_for_upload` filters
  banned authors, so it is hidden from everyone including the host. Now pins the pair that
  matters — the ban hides it, and the host's permanent removal survives an unban — and the
  UI leg it used to own is restored as a separate test on a reachable comment.

The export specs mint with `?kind=` now that a download ticket is bound to one archive, and
four of them assert the mint's 404 rather than the download's: with the kind always known,
the pre-check refuses up front instead of after charging a daily download for an archive
that cannot be served.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:44:48 +02:00

135 lines
5.7 KiB
TypeScript

/**
* USER_JOURNEYS.md §12 — release the export, see status, download.
*
* We don't drive a real export job here (the compression takes too long
* for E2E timing). Instead we forge the export-job rows via the db helper
* and assert the API behavior + UI banner state.
*/
import { test, expect } from '../../fixtures/test';
import { ExportPage } from '../../page-objects';
const SLUG = 'e2e-test-event';
test.describe('Export — release and download', () => {
test('/export shows the "not yet available" state before release', async ({
page,
guest,
signIn,
}) => {
const g = await guest('PreRelease');
await signIn(page, g);
const exportPage = new ExportPage(page);
await exportPage.goto();
// POSITIVE anchor first. An absence-only assertion ("no download button") is green on a
// blank page, a 404, or an unhydrated shell — i.e. it would pass even with the buttons
// rendered, if the locator were wrong. Pin the empty state we actually expect.
await expect(exportPage.notAvailableBanner).toBeVisible({ timeout: 10_000 });
// And only THEN the absence: no download affordance exists before release. This uses the
// real button label ("Download"), so rendering a download button here turns it red.
await expect(exportPage.downloadButtons).toHaveCount(0);
});
test('/export shows enabled download buttons once released and the jobs are done', async ({
page,
guest,
signIn,
db,
}) => {
const g = await guest('PostRelease');
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
await db.fakeExportJob(SLUG, 'html', 'done');
await signIn(page, g);
const exportPage = new ExportPage(page);
await exportPage.goto();
// The mirror of the test above: this is what proves the "before release" locators can
// actually SEE a download button when one exists. Without this, a typo'd locator makes
// the pre-release test unfalsifiable.
await expect(exportPage.notAvailableBanner).toHaveCount(0);
await expect(exportPage.zipDownloadButton).toBeVisible({ timeout: 10_000 });
await expect(exportPage.zipDownloadButton).toBeEnabled();
await expect(exportPage.htmlDownloadButton).toBeVisible();
await expect(exportPage.htmlDownloadButton).toBeEnabled();
});
test('export status API reflects released flag', async ({ guest, db }) => {
const g = await guest('ReleaseQuery');
let res = await fetch(
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status',
{
headers: { Authorization: `Bearer ${g.jwt}` },
}
);
let body: any = await res.json();
expect(body.released).toBe(false);
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
await db.fakeExportJob(SLUG, 'html', 'done');
res = await fetch(
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status',
{
headers: { Authorization: `Bearer ${g.jwt}` },
}
);
body = await res.json();
expect(body.released).toBe(true);
expect(body.zip.status).toBe('done');
expect(body.html.status).toBe('done');
});
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
// Browser downloads stream to disk via a top-level navigation, so the download
// endpoint authenticates with a single-use ticket (no Bearer header).
/** The raw mint response — `/export/ticket` pre-validates that the archive is actually
* servable, so an unavailable keepsake is refused HERE rather than after charging one of the
* guest's three daily downloads. */
async function mintTicketResponse(jwt: string, kind: 'zip' | 'html' = 'zip') {
return fetch(base + `/api/v1/export/ticket?kind=${kind}`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
});
}
test('ZIP download 404s for a `done` job at a RETIRED epoch', async ({ guest, db }) => {
const g = await guest('NotReady');
// The event is released and the zip job says `done` — but the job carries a dead epoch, which
// is exactly the state a reopen (or a superseded worker) leaves behind. Readiness is derived
// from `job.epoch = event.export_epoch`, so this archive is NOT current and must never be
// served. A 200 here would be the stale-keepsake bug leaking through the read path.
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
await db.setExportZipReady(SLUG, false); // retire the job to a dead epoch
// Refused at the MINT. This used to be asserted one step later, on the download, because the
// spec did not send `kind` and so skipped the pre-check entirely — now that a ticket is bound
// to an archive the kind is always known, and the guest is told the truth before a daily
// download is spent on an archive that cannot be served.
expect((await mintTicketResponse(g.jwt)).status).toBe(404);
});
test('ZIP download 404s when the job is current but the file is missing on disk', async ({
guest,
db,
}) => {
const g = await guest('ReadyNoFile');
// Released, and the job is `done` at the LIVE epoch — but no archive was ever written (we
// never ran a real export). The handler must 404 on the missing file, not 200/500 or serve
// something stale.
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
await db.setExportZipReady(SLUG, true);
// Same as above: the pre-check resolves the file on disk, so a `done` job whose archive is
// missing is refused at the mint rather than 404ing mid-download.
expect((await mintTicketResponse(g.jwt)).status).toBe(404);
});
});