Files
EventSnap/e2e/specs/10-flow-review/export-reopen-rerelease.spec.ts
fabi c48d43f5b3 test(e2e): cover the untested security lifecycles (PIN reset, logout-all, ban gating, caption)
Closes the coverage gaps the audit flagged and I verified were real:

  pin-reset-lifecycle.spec.ts (new): the whole "I forgot my PIN" journey (§4) had only its
    403 gate tested. Now: the always-204 non-enumeration contract (unknown name looks
    identical to known); dedup (ON CONFLICT); admins are EXCLUDED from the queue; the
    3-per-15-min throttle; and a host reset REVOKES the target's sessions (the pre-reset JWT
    401s afterward) and clears the request. Sessions are validated per-request against the
    DB, so the revoke is observable.

  logout-everywhere.spec.ts (new): DELETE /sessions ("sign out everywhere") had ZERO tests.
    Proves it revokes ALL of the caller's sessions across two devices (both tokens 401 after),
    not just the current one, and doesn't touch another user's sessions.

  media-gating: the file claimed delete AND ban-hide both revoke preview access, but only
    delete was exercised. Add the ban case — a banned uploader's gated preview 404s, same as
    a takedown. (Thumbnail shares the identical find_by_id_visible gate; the seed fixture
    produces no thumbnail derivative, so preview is the honest thing to assert.)

  export caption test: an edit after release regenerates the viewer (epoch bumps) while the
    ZIP is carried forward, not rebuilt — guards the fix in the previous commit.

Helpers: api.listPinResetRequests; db.countSessionsForUser / countPinResetRequestsForUser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:48:31 +02:00

703 lines
34 KiB
TypeScript

/**
* Regression guard — reopen→re-release export integrity + the stale-keepsake data-loss fix (H1).
*
* Two related bugs on this path:
*
* (1) Temp-file race: a second worker spawned by a re-release could stomp the first
* worker's FIXED temp path → a corrupt/truncated keepsake ZIP.
*
* (2) Stale keepsake (silent data loss): a worker from the PRIOR release, still streaming a
* snapshot taken BEFORE a reopen, would finish and unconditionally re-flip
* `export_*_ready = TRUE` on an archive that predated the reopen — so uploads added
* during the reopen window were permanently missing from a "ready" keepsake.
*
* The fix (migration 014): ONE monotonic `event.export_epoch`, bumped in the SAME UPDATE as any
* change to `export_released_at` — release and reopen are its only writers. Each export_job row
* carries a COPY of the epoch it was enqueued for. An export is downloadable IFF
* `released AND job.epoch = event.export_epoch AND job.status = 'done'` — readiness is DERIVED,
* never stored, so it cannot drift and no worker can resurrect it.
*
* The consequence that kills the whole bug class: a worker holding a retired epoch is INERT BY
* CONSTRUCTION. Every write it makes is guarded on `epoch` on the very row it updates, so it simply
* matches nothing — no cross-table EXISTS (which would be unsound under READ COMMITTED), no flag to
* flip, nothing to skip. Losing a race can no longer corrupt state; it can only waste work.
*
* Coverage:
* - churn integrity: rapid reopen→re-release yields exactly one INTACT ZIP, no stuck jobs.
* - completeness: an upload added during the reopen window IS present in the re-released
* keepsake (the direct data-loss regression).
* - supersession (re-release): a re-release SUPERSEDES an in-flight (running) job — it bumps the
* generation and regenerates, rather than leaving the stale worker to finalize.
* - supersession (reopen): a reopen ALONE bumps the event epoch, so a worker holding the
* pre-reopen epoch is retired and can publish nothing.
* - acceptance: an upload held open mid-body across a release is REJECTED, not silently dropped
* from the keepsake — the upload-path TOCTOU that was the real cause of the "stale keepsake" bug.
* Deterministic: the body is paused with the pre-flight check already passed.
* - takedown: deleting a photo AFTER release regenerates the keepsake without it.
*/
import { test, expect } from '../../fixtures/test';
import { Client } from 'pg';
import { execFileSync } from 'node:child_process';
import { writeFileSync, mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { seedUpload } from '../../helpers/seed';
import { uploadRaw, uploadPausedMidStream } from '../../helpers/upload-client';
import { SseListener } from '../../helpers/sse-listener';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
const SLUG = 'e2e-test-event';
const N_UPLOADS = 6;
/** A ZIP holding no entries: just the 22-byte end-of-central-directory record. */
const EMPTY_ZIP_BYTES = 22;
const PG = {
host: process.env.E2E_DB_HOST ?? 'localhost',
port: Number(process.env.E2E_DB_PORT ?? '55432'),
user: process.env.E2E_DB_USER ?? 'eventsnap_test',
password: process.env.E2E_DB_PASSWORD ?? 'eventsnap_test',
database: process.env.E2E_DB_NAME ?? 'eventsnap_test',
};
async function pgQuery<T = any>(sql: string): Promise<T[]> {
const c = new Client(PG);
await c.connect();
try {
return (await c.query(sql)).rows as T[];
} finally {
await c.end();
}
}
function post(path: string, jwt: string) {
return fetch(BASE + path, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` } });
}
async function exportStatus(jwt: string): Promise<any> {
const res = await fetch(BASE + '/api/v1/export/status', { headers: { Authorization: `Bearer ${jwt}` } });
return res.json();
}
async function mintTicket(jwt: string): Promise<string> {
const res = await post('/api/v1/export/ticket', jwt);
return (await res.json()).ticket;
}
async function waitExportDone(jwt: string) {
await expect
.poll(async () => {
const s = await exportStatus(jwt);
return s.released === true && s.zip?.status === 'done' && s.html?.status === 'done';
}, { timeout: 60_000, intervals: [500] })
.toBe(true);
}
/** Download the current ZIP keepsake and return its (non-directory) entry names. */
async function downloadZipEntries(jwt: string): Promise<string[]> {
const ticket = await mintTicket(jwt);
const res = await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
expect(res.status).toBe(200);
const bytes = Buffer.from(await res.arrayBuffer());
expect(bytes.subarray(0, 2).toString('latin1')).toBe('PK'); // ZIP magic — not a stomped file
// A ZIP with no entries is exactly its 22-byte end-of-central-directory record. That is
// structurally VALID (an event with nothing to export), but `unzip -t` exits non-zero on it — so
// check for it explicitly rather than letting it masquerade as corruption.
if (bytes.length === EMPTY_ZIP_BYTES) return [];
const dir = mkdtempSync(join(tmpdir(), 'es-zip-'));
const zipPath = join(dir, 'Gallery.zip');
writeFileSync(zipPath, bytes);
try {
execFileSync('unzip', ['-t', zipPath], { stdio: 'pipe' }); // CRC integrity; throws on corruption
} catch (e: any) {
const out = `${e.stdout?.toString() ?? ''}${e.stderr?.toString() ?? ''}`;
throw new Error(`keepsake ZIP failed integrity check (${bytes.length} bytes):\n${out}`);
}
return execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' })
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.endsWith('/'));
}
/** The epoch the CURRENT zip job row was enqueued for. */
async function zipJobEpoch(): Promise<number> {
const [row] = await pgQuery<{ epoch: string }>(
`SELECT ej.epoch FROM export_job ej JOIN event e ON e.id = ej.event_id
WHERE e.slug = '${SLUG}' AND ej.type = 'zip'`
);
return Number(row.epoch);
}
/** The event's authoritative generation counter. */
async function eventEpoch(): Promise<number> {
const [row] = await pgQuery<{ export_epoch: string }>(
`SELECT export_epoch FROM event WHERE slug = '${SLUG}'`
);
return Number(row.export_epoch);
}
test.describe('Flow re-review — reopen→re-release export integrity + stale-keepsake (H1)', () => {
// The real export job runs image processing; give it head-room over the tiny fixtures.
test.setTimeout(120_000);
test('rapid release/reopen/re-release yields exactly one INTACT ZIP and no stuck jobs', async ({
host,
}) => {
// Seed real, decodable uploads while the event is open.
for (let i = 0; i < N_UPLOADS; i++) {
await seedUpload(host.jwt, { caption: `pic ${i}` });
}
// First release → export starts and uploads lock (release ⇒ lock).
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
// Post-release uploads must be rejected (belt-and-suspenders on top of the lock).
const rejected = await uploadRaw(host.jwt, readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')), {
filename: 'late.jpg',
contentType: 'image/jpeg',
});
expect(rejected.status).toBe(403);
// Churn: reopen (retires the generation) then re-release, several times in quick
// succession. This is the path that used to double-spawn workers over a shared temp file.
// End on a release so the gallery is left released.
for (let i = 0; i < 4; i++) {
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
const rel = await post('/api/v1/host/gallery/release', host.jwt);
expect(rel.status).toBe(204);
}
await waitExportDone(host.jwt);
// No export_job may be left stuck 'running'.
const jobs = await pgQuery<{ type: string; status: string }>(
`SELECT ej.type::text AS type, ej.status::text AS status
FROM export_job ej JOIN event e ON e.id = ej.event_id
WHERE e.slug = '${SLUG}'`
);
expect(jobs.length).toBe(2);
for (const j of jobs) expect(j.status, `${j.type} job status`).toBe('done');
// Exactly one entry per seeded upload — no entry lost to a stomped temp file, none
// duplicated by a second worker.
const listing = await downloadZipEntries(host.jwt);
expect(listing.length, `zip entries:\n${listing.join('\n')}`).toBe(N_UPLOADS);
});
test('an upload added during the reopen window IS present in the re-released keepsake (H1)', async ({
host,
}) => {
// Release an initial keepsake of 3 uploads.
for (let i = 0; i < 3; i++) await seedUpload(host.jwt, { caption: `orig ${i}` });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
expect((await downloadZipEntries(host.jwt)).length).toBe(3);
// Reopen (invalidates the release) → add a 4th upload while unlocked → re-release.
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
await seedUpload(host.jwt, { caption: 'added-during-reopen' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
// The re-released keepsake MUST contain all 4 — the stale-keepsake bug would have left
// the download at the pre-reopen snapshot of 3, silently dropping the new upload.
const listing = await downloadZipEntries(host.jwt);
expect(listing.length, `zip entries:\n${listing.join('\n')}`).toBe(4);
});
test('re-release SUPERSEDES an in-flight (running) export — bumps the generation', async ({
host,
}) => {
// Deterministic proof of the generation guard, independent of export timing. We pin the
// zip job to `running` with a sentinel to mimic a worker from a prior release that is
// still mid-export, then drive reopen→re-release.
//
// OLD behavior (the bug): the re-enqueue left a `running` row alone, so the stale worker
// would eventually finalize and re-flip the ready flag on its pre-reopen snapshot.
// NEW behavior (the fix): the re-enqueue bumps `epoch` and resets the row, so the
// stale generation is superseded — its sentinel is wiped and a fresh worker regenerates.
await seedUpload(host.jwt, { caption: 'a' });
await seedUpload(host.jwt, { caption: 'b' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
const seqBefore = await zipJobEpoch();
// Simulate an in-flight worker owning the zip job at the current generation.
await pgQuery(
`UPDATE export_job ej SET status = 'running', progress_pct = 77
FROM event e
WHERE e.id = ej.event_id AND e.slug = '${SLUG}' AND ej.type = 'zip'`
);
// Reopen (clears release + ready flags AND bumps every export_job's epoch) then
// re-release (bumps + resets again). Either bump alone supersedes the pinned running row.
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
// The fresh generation must settle to done at a HIGHER epoch — proving the stale
// running row was superseded (not left to finalize) and the sentinel is gone.
await waitExportDone(host.jwt);
const [row] = await pgQuery<{ status: string; progress_pct: number; epoch: string }>(
`SELECT ej.status::text AS status, ej.progress_pct, ej.epoch
FROM export_job ej JOIN event e ON e.id = ej.event_id
WHERE e.slug = '${SLUG}' AND ej.type = 'zip'`
);
expect(row.status, 'zip job status').toBe('done');
expect(Number(row.progress_pct), 'zip job progress').toBe(100);
expect(Number(row.epoch), 'epoch advanced past the superseded generation').toBeGreaterThan(
seqBefore
);
});
test('a reopen ALONE bumps the epoch — a pre-reopen worker is retired and can publish nothing', async ({
host,
}) => {
// Deterministic proof that a reopen retires the current generation. The nasty interleavings
// (a worker finalizing around a reopen/re-release) can't be forced by sub-millisecond timing —
// but their ROOT cause is observable: whether a reopen moves the epoch. If it does, the epoch
// the worker captured is gone for good, so its epoch-guarded finalize matches nothing no matter
// when it lands, and there is no separate ready flag left for it to resurrect.
await seedUpload(host.jwt, { caption: 'x' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
const epochAtRelease = await eventEpoch();
expect(await zipJobEpoch(), 'the done job carries the live epoch').toBe(epochAtRelease);
// Reopen only — no re-release.
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
const epochAfterReopen = await eventEpoch();
expect(
epochAfterReopen,
`reopen must move the event epoch past ${epochAtRelease}, retiring any worker holding it`
).toBeGreaterThan(epochAtRelease);
// The job row still carries the OLD epoch — so it no longer matches the event's, which is
// exactly what makes it (and any worker holding it) invisible and inert.
expect(await zipJobEpoch(), 'the stale job row is left behind at the retired epoch').toBe(
epochAtRelease
);
// Readiness is DERIVED, so there is no flag to check — the keepsake simply stops being
// downloadable the moment the epoch moves. Assert the observable contract instead.
const [ev] = await pgQuery<{ released_at: string | null }>(
`SELECT export_released_at AS released_at FROM event WHERE slug = '${SLUG}'`
);
expect(ev.released_at, 'reopen clears the release timestamp').toBeNull();
const ticket = await mintTicket(host.jwt);
const dl = await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
expect(dl.status, 'a reopened event serves no keepsake').toBe(404);
});
test('open ‖ release churn always converges to a consistent, downloadable keepsake', async ({
host,
}) => {
// Convergence STRESS test, not a regression guard — be honest about what this does and does
// not prove. `open_event` reopens the event AND bumps every export_job generation, and those
// two statements must be atomic (they run in one transaction): if they were separate
// autocommits, a re-release could slip between them and spawn a FRESH worker whose generation the
// reopen's second statement would then retire — orphaning it and stranding its row at `running`
// forever with the host unable to retry. open_event is now a single UPDATE, so this cannot occur.
//
// That interleaving is NOT reproducible from outside the process (verified: this test passes
// against a deliberately non-transactional build even with ~90 concurrent open/release pairs —
// the window is microseconds). So it does not guard the atomicity fix; that rests on the
// transaction being correct by construction. What this DOES pin down is that hammering both
// endpoints never leaves the export pipeline in a wedged state — no stuck job, no corrupt or
// missing archive — which is the user-visible property we actually care about.
await seedUpload(host.jwt, { caption: 'race' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
// Hammer both endpoints concurrently. The bad window (between open_event's two autocommit
// statements) is microseconds wide, so a couple of sequential pairs never hit it — we need
// many in-flight requests contending for pool connections to land a release inside it.
// Deliberately unchecked: whichever loses a race legitimately 4xx's ("already released" /
// nothing to reopen). We only care that no interleaving strands a job.
for (let round = 0; round < 15; round++) {
const calls: Promise<unknown>[] = [];
for (let i = 0; i < 6; i++) {
calls.push(post('/api/v1/host/event/open', host.jwt));
calls.push(post('/api/v1/host/gallery/release', host.jwt));
}
await Promise.all(calls);
}
// Land deterministically in a released state so a fresh generation is definitely in flight.
await post('/api/v1/host/event/open', host.jwt);
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
// An orphaned worker leaves its row `running` forever, so this poll would time out.
await waitExportDone(host.jwt);
const jobs = await pgQuery<{ type: string; status: string }>(
`SELECT ej.type::text AS type, ej.status::text AS status
FROM export_job ej JOIN event e ON e.id = ej.event_id
WHERE e.slug = '${SLUG}'`
);
expect(jobs.length).toBe(2);
for (const j of jobs) expect(j.status, `${j.type} job must not be stranded`).toBe('done');
// And the keepsake is actually downloadable and intact.
expect((await downloadZipEntries(host.jwt)).length).toBe(1);
});
test('an upload STUCK MID-BODY when the release lands is rejected, not silently lost', async ({
host,
}) => {
// THE bug that survived three rounds of fixes to the export state machine — because it was never
// in the state machine. `upload` checked `uploads_locked_at` BEFORE streaming the body (minutes,
// for a 500 MB video) and committed the row without re-checking. A release landing mid-upload let
// the row commit AFTER the export snapshot: the photo showed up in the live feed and was
// PERMANENTLY missing from the keepsake, with nothing to regenerate it.
//
// This test is DETERMINISTIC, not a hoped-for race. The upload is held open mid-body with its
// pre-flight check already passed, so the release provably lands inside the exact window. The
// server must then reject it at commit time (`FOR SHARE` re-check) — anything else means a photo
// the server accepted is not in the archive.
await seedUpload(host.jwt, { caption: 'pre-race' });
const img = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
const split = Math.floor(img.length / 2);
const paused = uploadPausedMidStream(host.jwt, img.subarray(0, split), img.subarray(split), {
filename: 'stuck.jpg',
contentType: 'image/jpeg',
});
// Wait until the server is genuinely mid-body (past its pre-flight lock check).
await paused.checkPassed;
// Land the release squarely inside that window.
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
// Now let the body finish. The pre-flight check passed, so ONLY the commit-time re-check can
// save this photo from becoming a phantom.
paused.finish();
const res = await paused.response;
expect(
res.status,
'an upload whose body completed after the release MUST be rejected (403 uploads_locked). ' +
'A 201 here means the server accepted a photo it will never put in the keepsake — silent, ' +
'permanent data loss.'
).toBe(403);
expect((await res.json()).error).toBe('uploads_locked');
// And the keepsake holds exactly the one upload that was genuinely committed before the release.
await waitExportDone(host.jwt);
const listing = await downloadZipEntries(host.jwt);
expect(listing.length, `zip entries:\n${listing.join('\n')}`).toBe(1);
});
test('deleting a photo AFTER release regenerates the keepsake without it (takedown)', async ({
host,
}) => {
// A host deleting a photo on a removal request used to remove it from the feed but leave it in
// the already-generated archive FOREVER — the `if ready { continue }` skip guaranteed the
// export was never rebuilt. The keepsake is the one place it most needs to be gone.
const keep = await seedUpload(host.jwt, { caption: 'keep' });
const takedown = await seedUpload(host.jwt, { caption: 'takedown' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
expect((await downloadZipEntries(host.jwt)).length).toBe(2);
// Take it down while the gallery is released.
const del = await fetch(BASE + `/api/v1/host/upload/${takedown}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${host.jwt}` },
});
expect(del.status).toBe(204);
// The keepsake must rebuild — and must NOT still contain the removed photo.
await waitExportDone(host.jwt);
const listing = await downloadZipEntries(host.jwt);
expect(listing.length, `zip entries:\n${listing.join('\n')}`).toBe(1);
expect(
listing.some((n) => n.includes(takedown)),
'the taken-down photo must be gone from the regenerated keepsake'
).toBe(false);
expect(listing.some((n) => n.includes(keep)), 'the kept photo survives').toBe(true);
});
test('BANNING a guest after release removes their photos from the keepsake', async ({
api,
host,
guest,
}) => {
// `ban_user` calls `invalidate_and_arm(Affects::Both)` — but every ban in the suite ran against
// an UNRELEASED event, where `invalidate_and_arm` returns early. So this regeneration path was
// dead code under test.
//
// The stakes are the whole point of a ban: the host bans someone for posting something abusive,
// and if the keepsake doesn't rebuild, that content stays in the archive every guest downloads,
// forever. The export query already filters `is_banned` — so the pipeline AGREES the content
// shouldn't be there; only the regeneration was missing.
const offender = await guest('Offender');
const innocent = await guest('Innocent');
const badPhoto = await seedUpload(offender.jwt, { caption: 'abusive' });
const goodPhoto = await seedUpload(innocent.jwt, { caption: 'lovely' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
expect(await downloadZipEntries(host.jwt)).toHaveLength(2);
await api.banUser(host.jwt, offender.userId);
await waitExportDone(host.jwt);
const listing = await downloadZipEntries(host.jwt);
expect(
listing.some((n) => n.includes(badPhoto)),
"a banned guest's photo must not survive in the keepsake everyone downloads"
).toBe(false);
expect(listing.some((n) => n.includes(goodPhoto)), 'everyone else keeps their photos').toBe(true);
expect(listing).toHaveLength(1);
});
test('UNBANNING a guest after release restores their photos to the keepsake', async ({
api,
host,
guest,
}) => {
// The mirror image, and the one that LOSES data. A host bans someone by mistake (or bans, then
// reconsiders) — unban must put their photos back into the archive. If the regeneration is
// missing, that guest's photos are absent from the keepsake everyone keeps, and after the
// wedding they are gone: the export is the only copy anyone takes home.
const g = await guest('Forgiven');
const photo = await seedUpload(g.jwt, { caption: 'restore me' });
await seedUpload(host.jwt, { caption: 'host photo' });
await api.banUser(host.jwt, g.userId);
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
// Banned at release time → their photo is correctly absent.
expect(await downloadZipEntries(host.jwt)).toHaveLength(1);
const unban = await post(`/api/v1/host/users/${g.userId}/unban`, host.jwt);
expect(unban.status).toBe(204);
await waitExportDone(host.jwt);
const listing = await downloadZipEntries(host.jwt);
expect(
listing.some((n) => n.includes(photo)),
'an unbanned guest must get their photos back in the keepsake — it is the only copy anyone keeps'
).toBe(true);
expect(listing).toHaveLength(2);
});
test('a HOST comment takedown after release keeps the keepsake downloadable', async ({
host,
guest,
}) => {
// `DELETE /host/comment/{id}` had ZERO test hits of any kind, and it is one of the two callers
// of the ViewerOnly carry-forward. A comment doesn't live in the ZIP (which holds media), so the
// ZIP must be CARRIED FORWARD, not rebuilt — but it must still be downloadable afterwards, and
// the viewer must lose the comment.
const g = await guest('Rude');
const uploadId = await seedUpload(host.jwt, { caption: 'pic' });
const cRes = await fetch(BASE + `/api/v1/upload/${uploadId}/comments`, {
method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: 'unfreundlich' }),
});
const commentId = (await cRes.json()).id;
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
const del = await fetch(BASE + `/api/v1/host/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${host.jwt}` },
});
expect(del.status).toBe(204);
// The keepsake must still be downloadable, with its media intact.
await waitExportDone(host.jwt);
expect(await downloadZipEntries(host.jwt)).toHaveLength(1);
expect(await zipJobEpoch()).toBe(await eventEpoch());
});
test('editing a caption AFTER release regenerates the viewer but carries the ZIP forward', async ({
host,
}) => {
// edit_upload used to update the caption and NOTHING else — no keepsake invalidation. A caption
// lives in the HTML viewer (the ZIP holds media only), so after release the downloadable viewer
// kept showing the OLD caption forever while the live feed showed the new one. Editing is
// intentionally allowed post-release (like comments), so the fix is to regenerate, not forbid:
// invalidate_and_arm(ViewerOnly) — rebuild the viewer, carry the finished ZIP forward untouched.
const uploadId = await seedUpload(host.jwt, { caption: 'tippfehlr' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
const zipEpochBefore = await zipJobEpoch();
const eventEpochBefore = await eventEpoch();
expect(zipEpochBefore).toBe(eventEpochBefore);
// The edit is allowed (not blocked by the release lock) ...
const res = await fetch(BASE + `/api/v1/upload/${uploadId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${host.jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ caption: 'korrigiert' }),
});
expect(res.status).toBe(200);
// ... and it bumped the epoch (the viewer must rebuild) ...
await expect.poll(async () => await eventEpoch(), { timeout: 10_000 }).toBeGreaterThan(eventEpochBefore);
// ... while the ZIP was CARRIED FORWARD, not rebuilt: its row rides the new epoch (the media
// didn't change, so a full ZIP rebuild would be wasted work).
await waitExportDone(host.jwt);
expect(await zipJobEpoch(), 'the ZIP must be carried forward to the new epoch').toBe(
await eventEpoch()
);
// The keepsake is still downloadable with its single photo intact.
expect(await downloadZipEntries(host.jwt)).toHaveLength(1);
});
test('a comment deleted while the ZIP is still BUILDING does not strand the ZIP', async ({
host,
guest,
}) => {
// A comment-only change (`Affects::ViewerOnly`) carries the finished ZIP into the new epoch
// rather than spending minutes rebuilding an archive whose media didn't change. But "finished"
// is a PRECONDITION, and between `release_gallery` and the ZIP worker completing it is false —
// for a real multi-GB gallery, for MINUTES. Deleting a comment in that window is an utterly
// ordinary thing to do.
//
// The carry-forward UPDATE is guarded on `status = 'done'`, so in that window it matched
// NOTHING — and ViewerOnly re-armed only the viewer. The ZIP row was left at the retired epoch,
// the in-flight worker finished and wrote `done` at an epoch `export_current` no longer matches,
// and `GET /export/zip` 404'd FOREVER. Nothing re-armed it: recovery only runs at boot, and
// `release_gallery` refuses an already-released event.
//
// Fix: the carry-forward's own `rows_affected()` decides. Nothing carried ⇒ rebuild the ZIP too.
const g = await guest('Commenter');
const uploadId = await seedUpload(host.jwt, { caption: 'pic' });
const cRes = await fetch(BASE + `/api/v1/upload/${uploadId}/comments`, {
method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: 'schoenes foto' }),
});
expect(cRes.status).toBe(201);
const commentId = (await cRes.json()).id;
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await waitExportDone(host.jwt);
// Put the ZIP back into the state it occupies for most of a real release: still building.
// Everything else in this test is the real product.
await pgQuery(
`UPDATE export_job ej SET status = 'running', progress_pct = 50
FROM event e WHERE e.id = ej.event_id AND e.slug = '${SLUG}' AND ej.type = 'zip'`
);
// A guest deletes their own comment → Affects::ViewerOnly.
const del = await fetch(BASE + `/api/v1/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${g.jwt}` },
});
expect(del.status).toBe(204);
// The ZIP must have been RE-ARMED at the live epoch, not abandoned at the retired one.
expect(await zipJobEpoch(), 'the ZIP must not be stranded at a retired epoch').toBe(
await eventEpoch()
);
// And it must actually become downloadable again, with its media intact.
await waitExportDone(host.jwt);
expect(await downloadZipEntries(host.jwt)).toHaveLength(1);
});
test('a release broadcasts export-progress 100 for both types and one export-available', async ({
host,
}) => {
// The ready-flip is now conditional, and a SUPERSEDED worker deliberately emits neither event.
// That makes the happy path worth pinning: `/export/status` polling (what waitExportDone uses)
// reads the export_job row, NOT the SSE — so a regression that silently stopped emitting these
// would leave every other test green while the /host and /export pages stopped updating live.
const sse = new SseListener();
await sse.start(host.jwt);
await seedUpload(host.jwt, { caption: 'sse' });
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
for (const type of ['zip', 'html']) {
await sse.waitForEvent(
'export-progress',
(e) => e.data?.type === type && e.data?.progress_pct === 100,
30_000
);
}
// Fires once BOTH ready flags are set — the authoritative "keepsake is downloadable" signal.
await sse.waitForEvent('export-available', () => true, 30_000);
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);
});
});