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>
85 lines
4.1 KiB
TypeScript
85 lines
4.1 KiB
TypeScript
/**
|
||
* Covers the HTML export's VIDEO path (perf/stability fix P4). The export used to
|
||
* copy every original — including full-size videos — into a temp dir and then re-read
|
||
* them into the ZIP (transient 2× disk). It now streams video originals straight from
|
||
* disk into the archive. The image-only export specs never exercised that branch, so
|
||
* this drives a real video upload → export → and proves the video entry lands in
|
||
* Memories.zip (i.e. the streamed-from-original path works and the video isn't dropped).
|
||
*
|
||
* NOTE ON A PREVIOUS VERSION OF THIS COMMENT. It used to read: "The fixture clip is <1s, so ffmpeg
|
||
* extracts no thumbnail frame — but exits 0, so the compression worker keeps the upload. That's the
|
||
* intended shape here." None of that was intended. `-ss` sat AFTER `-i` (an output-side seek), so
|
||
* against `sample.mp4` — which is exactly 1.000 s — ffmpeg exited 0 having written nothing, and
|
||
* both the worker and the export gated on the exit status. The missing thumbnail was observed here
|
||
* and written down as expected behaviour instead of investigated; every video test in the suite ran
|
||
* against that one boundary fixture, and none of them ever fetched the poster.
|
||
*
|
||
* The seek is now input-side with a 0 s fallback and the artifact is verified rather than the exit
|
||
* code, so this clip DOES get a thumbnail. The assertion at the bottom pins that.
|
||
*/
|
||
import { test, expect } from '../../fixtures/test';
|
||
import { uploadRaw } from '../../helpers/upload-client';
|
||
import { readFileSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { BASE } from '../../helpers/env';
|
||
|
||
test.describe('Export — video streaming (P4)', () => {
|
||
test('a real video upload is streamed into Memories.zip (not dropped)', async ({ host }) => {
|
||
test.setTimeout(60_000);
|
||
const bearer = { Authorization: `Bearer ${host.jwt}` };
|
||
|
||
// Seed a real video upload owned by the host.
|
||
const mp4 = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.mp4'));
|
||
const up = await uploadRaw(host.jwt, mp4, { filename: 'clip.mp4', contentType: 'video/mp4' });
|
||
expect(up.status).toBe(201);
|
||
const { id } = await up.json();
|
||
|
||
// Release the gallery → spawns the real zip + html export jobs.
|
||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||
method: 'POST',
|
||
headers: bearer,
|
||
});
|
||
expect(rel.status).toBe(204);
|
||
|
||
// Wait for the HTML (Memories.zip) job — that's the one whose media staging P4 changed.
|
||
await expect
|
||
.poll(
|
||
async () => {
|
||
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
|
||
return (await res.json()).html?.status;
|
||
},
|
||
{ timeout: 45_000, intervals: [500] }
|
||
)
|
||
.toBe('done');
|
||
|
||
// Download Memories.zip via the gated single-use ticket.
|
||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
|
||
method: 'POST',
|
||
headers: bearer,
|
||
});
|
||
const { ticket } = await ticketRes.json();
|
||
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
|
||
expect(dl.status).toBe(200);
|
||
|
||
const bytes = new Uint8Array(await dl.arrayBuffer());
|
||
// Valid ZIP (PK local-file-header magic).
|
||
expect(Array.from(bytes.subarray(0, 2))).toEqual([0x50, 0x4b]);
|
||
|
||
// ZIP entry names are stored verbatim (uncompressed) in the archive, so the video's
|
||
// media entry name appears as plaintext bytes. Its presence means the streamed
|
||
// video entry was written; if the stream had failed, entry.close() would have
|
||
// errored and the job would never have reached 'done'.
|
||
const needle = `media/${id}.mp4`;
|
||
const haystack = new TextDecoder('latin1').decode(bytes);
|
||
expect(haystack.includes(needle), `Memories.zip must contain ${needle}`).toBe(true);
|
||
|
||
// And its poster is really in the archive. This clip is 1.000 s — the exact case the old
|
||
// output-side seek produced nothing for, silently, while `data.json` still advertised the
|
||
// entry. See the note at the top of this file.
|
||
expect(
|
||
haystack.includes(`media/${id}_thumb.jpg`),
|
||
`Memories.zip must contain the poster for ${id}, not just reference it`
|
||
).toBe(true);
|
||
});
|
||
});
|