/** * Regression guard — the keepsake download must actually download, in WebKit. * * The bug this exists to catch: `/export` streams the archive by pointing a HIDDEN, * SAME-ORIGIN iframe at `/api/v1/export/zip` (deliberately — a top-level navigation to a * 404/429 would unload the PWA). Caddy stamped a site-wide `X-Frame-Options: DENY` that * also covered `/api/*`. Blink hands a `Content-Disposition: attachment` response to the * download manager at the network layer, so Chromium never noticed; WebKit enforces XFO on * the frame navigation FIRST and aborts the load. Result: on iOS Safari — the app's primary * platform — tapping Download did nothing, silently, with no error anywhere. * * Why the old suite was structurally blind to it: * - `06-export` ran on `chromium-desktop` only (webkit-iphone's testMatch excluded it), * - and no test in the entire suite ever CLICKED a download button; every archive * assertion used Node `fetch`, which has no frame and therefore no XFO enforcement. * * So this spec must keep both properties to be worth anything: a real click, in WebKit. */ import { test, expect } from '../../fixtures/test'; import { ExportPage } from '../../page-objects'; import { BASE } from '../../helpers/env'; const SLUG = 'e2e-test-event'; function post(path: string, jwt: string) { return fetch(BASE + path, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` } }); } /** The real export job runs image processing; give it head-room over the tiny fixtures. */ async function releaseAndWait(jwt: string) { expect((await post('/api/v1/host/gallery/release', jwt)).status).toBe(204); await expect .poll( async () => { const res = await fetch(BASE + '/api/v1/export/status', { headers: { Authorization: `Bearer ${jwt}` }, }); const s = await res.json(); return s.released === true && s.zip?.status === 'done'; }, { timeout: 60_000, intervals: [500] } ) .toBe(true); } test.describe('Export — the download actually fires in the browser', () => { test.slow(); test('clicking Download triggers a real download event', async ({ page, host, signIn, db }) => { await db.setExportReleased(SLUG, false); await releaseAndWait(host.jwt); await signIn(page, host); const exportPage = new ExportPage(page); await exportPage.goto(); await expect(exportPage.zipDownloadButton).toBeEnabled({ timeout: 10_000 }); // Capture the frame-level refusal that XFO produces, so a failure reports the CAUSE // rather than just a timeout. WebKit logs "Refused to display ... in a frame because it // set 'X-Frame-Options'"; Chromium logs nothing here, which is the whole problem. const refusals: string[] = []; page.on('console', (m) => { if (/X-Frame-Options|Refused to display/i.test(m.text())) refusals.push(m.text()); }); const downloadPromise = page.waitForEvent('download', { timeout: 30_000 }); await exportPage.zipDownloadButton.click(); const download = await downloadPromise.catch((err) => { throw new Error( `No download event fired after clicking the ZIP button.` + (refusals.length ? ` The browser refused the iframe navigation: ${refusals.join(' | ')}` : ' No X-Frame-Options refusal was logged; check the ticket/readiness path.') + `\n${err}` ); }); expect(download.suggestedFilename()).toMatch(/\.zip$/i); // The stream must produce real bytes, not a zero-length placeholder. const path = await download.path(); expect(path).toBeTruthy(); expect(refusals, 'no frame should have been refused').toEqual([]); }); test('the export endpoints are framable same-origin; everything else stays DENY', async () => { // Locks the Caddy carve-out itself, independently of any browser. Cheap, and it fails // loudly at the exact layer that regressed if someone reinstates a blanket DENY. for (const path of ['/api/v1/export/zip', '/api/v1/export/html']) { const res = await fetch(BASE + path); expect(res.headers.get('x-frame-options')?.toUpperCase(), `${path} must be framable`).toBe( 'SAMEORIGIN' ); } for (const path of ['/', '/api/v1/feed', '/api/v1/event']) { const res = await fetch(BASE + path); expect(res.headers.get('x-frame-options')?.toUpperCase(), `${path} must stay DENY`).toBe( 'DENY' ); } }); });