Files
EventSnap/e2e/specs/06-export/ticket-refused-mint.spec.ts
fabi 06e0bea0e9 fix(export): a refused download mint no longer strands the running transfer
`/export/ticket` mints before charging the daily limit, deliberately: charging
first meant a store-capacity 503 — a server-side condition the guest cannot see
or cause — still cost one of their three daily downloads, with no refund path.

But a mint refused with 429 left its ticket in the store. Download tickets live
six hours (they must, so a 1.4 GB transfer can resume with `Range`), and the
per-session cap is four tickets OF THE SAME KIND. So:

  the transfer starts on ticket A -> the bar looks stuck on venue wifi -> the
  guest taps "Herunterladen" again -> mints 2 and 3 succeed, 4 and 5 return 429
  but still mint -> the fifth evicts the oldest download ticket for the session,
  which is A -> the transfer drops, resumes, and 401s -> re-minting is
  impossible, they are at the daily limit

The keepsake is unreachable until the next day, for tapping a button that
appeared to do nothing. This is the failure `sse_churn_cannot_evict_a_running_
download` was written to prevent, reintroduced through the one channel that test
does not cover: download tickets evicting each other.

Discarding the ticket on the refusal path keeps both properties that put the
mint first — a capacity 503 still costs no download, and a refused download now
costs no slot.

Also corrects three doc comments in this path still describing download tickets
as "single-use, 30s TTL", false since they were made resumable. Stale comments
here have already sent one review down the wrong path.

The new spec asserts the two 429s explicitly, so it cannot pass vacuously if the
daily limit stops being enforced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:45:08 +02:00

100 lines
3.9 KiB
TypeScript

/**
* A mint that is REFUSED must not cost the guest the download they already have running.
*
* `/export/ticket` issues the ticket before charging the daily limit, deliberately: charging first
* meant a store-capacity 503 — a server-side condition the guest cannot see or cause — still spent
* one of their three daily downloads, with no refund path.
*
* But the ticket a refused mint created was left in the store, and it occupies a slot there for six
* hours, because a download ticket is long-lived so a multi-GB transfer can resume with `Range`.
* The per-session cap is four tickets of the same kind, so:
*
* the 1.4 GB transfer starts on ticket A → the progress bar looks stuck on venue wifi → the guest
* taps "Herunterladen" again → mints 2 and 3 succeed, 4 and 5 are refused with 429 but STILL mint
* → the fifth evicts the oldest download ticket for the session, which is A
* → the transfer drops, resumes with `Range`, and 401s
* → re-minting is impossible: they are at the daily limit
*
* The keepsake is then unreachable until the next day, for tapping a button that appeared to do
* nothing. A refused mint now discards its own ticket.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
import { BASE } from '../../helpers/env';
test.describe('Export — a refused mint does not evict a running download', () => {
test('the first ticket still works after the daily limit has refused later mints', async ({
host,
api,
adminToken,
}) => {
test.setTimeout(60_000);
const bearer = { Authorization: `Bearer ${host.jwt}` };
await seedUpload(host.jwt, { caption: 'keepsake' });
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');
// The e2e reseed forces every limiter OFF, so without this the daily limit never bites and the
// whole test passes vacuously. `export_rate_per_day` is seeded at 3 (migration 005).
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
export_rate_enabled: 'true',
});
try {
const mint = async () =>
fetch(`${BASE}/api/v1/export/ticket?kind=zip`, { method: 'POST', headers: bearer });
// 1. The ticket the "running" transfer holds.
const first = await mint();
expect(first.status).toBe(200);
const ticketA = (await first.json()).ticket as string;
expect(ticketA).toBeTruthy();
// 2. Two more legitimate mints, exhausting the day's three.
for (const i of [2, 3]) {
const r = await mint();
expect(r.status, `mint ${i} is still within the daily allowance`).toBe(200);
}
// 3. Two refused mints — the impatient taps. This is the CONTROL: if these came back 200 the
// limit was not in force and step 4 would prove nothing.
for (const i of [4, 5]) {
const r = await mint();
expect(r.status, `mint ${i} must be refused — the daily limit is spent`).toBe(429);
}
// 4. The running transfer resumes. Its ticket must have survived the refused mints: it is the
// guest's only remaining way to reach the keepsake today.
const resumed = await fetch(
`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticketA)}`
);
expect(
resumed.status,
'a refused mint must not evict the ticket an in-flight download is holding'
).toBe(200);
await resumed.arrayBuffer();
} finally {
await api.patchConfig(adminToken, {
rate_limits_enabled: 'false',
export_rate_enabled: 'false',
});
}
});
});