diff --git a/e2e/loadtest/upload-integrity-check.mjs b/e2e/loadtest/upload-integrity-check.mjs new file mode 100644 index 0000000..0f25b96 --- /dev/null +++ b/e2e/loadtest/upload-integrity-check.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * Drives a REAL upload through the composer's file picker and proves the bytes survived. + * + * Written during the v0.18.3–v0.18.6 run of hotfixes, which were all one failure wearing + * different masks: the browser handing the queue something that was not the file. First an + * empty body (WebKit stores a picked `File` as a reference to an OS file iOS then deletes), + * then — once the bytes were copied — the risk of a SHORT one, because that copy is a + * multi-second chunked loop for a video and the same purge can land partway through it. + * + * Every check that missed those bugs shared one shortcut: it asserted the upload was + * ACCEPTED. A truncated file is accepted. It passes the magic-byte sniff, the size cap and the + * decode budget, is stored, gets a preview, and shows in the gallery — and is still not the + * guest's video. So this asserts the only thing that actually settles it: fetch the stored + * original back and compare its SHA-256 to the file on disk. + * + * Two paths matter and they are not the same code: + * * at or below MATERIALISE_CHUNK_BYTES (4 MB) the copy is a single `arrayBuffer()` + * * above it, a chunked loop — the one that can produce a short blob + * Pass at least one file of each, or the run proves half of what it claims. + * + * Requires the sim stack (e2e/docker-compose.sim.yml) on :3102. + * + * node e2e/loadtest/upload-integrity-check.mjs photo.jpg big-video.mp4 + */ +import { chromium, devices } from '@playwright/test'; +import { readFile, stat } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { basename } from 'node:path'; + +const BASE = process.env.SIM_BASE ?? 'http://localhost:3102'; +const API = `${BASE}/api/v1`; +/** Mirrors MATERIALISE_CHUNK_BYTES in frontend/src/lib/upload-queue.ts. */ +const CHUNK_BYTES = 4 * 1024 * 1024; + +const sha = (buf) => createHash('sha256').update(buf).digest('hex'); + +const files = process.argv.slice(2); +if (!files.length) { + console.error('usage: upload-integrity-check.mjs [file...] (include one >4 MB)'); + process.exit(2); +} + +async function main() { + const browser = await chromium.launch(); + const context = await browser.newContext({ ...devices['Pixel 7'] }); + // The guide is a modal over the composer; leaving it up means the picker is never reached. + await context.addInitScript(() => localStorage.setItem('eventsnap_guide_seen', '1')); + const page = await context.newPage(); + const pageErrors = []; + page.on('pageerror', (e) => pageErrors.push(String(e).slice(0, 140))); + + await page.goto(`${BASE}/join`, { waitUntil: 'load' }); + await page.waitForTimeout(1200); + await page.fill('input[type=text]', `Integrity ${Date.now() % 100000}`); + await page.locator('button[type=submit]').first().click(); + await page.waitForTimeout(4500); + for (const label of ['Weiter', 'Verstanden', 'Los geht']) { + const btn = page.getByRole('button', { name: new RegExp(label, 'i') }); + if ((await btn.count()) && (await btn.first().isVisible())) { + await btn.first().click(); + await page.waitForTimeout(700); + break; + } + } + const token = await page.evaluate(() => localStorage.getItem('eventsnap_jwt')); + + // Report which upload entries the sheet offers, so a run also records whether + // PUBLIC_CAMERA_ENABLED was in effect — the composer path differs with it. + await page.locator('button[aria-label="Hochladen"]').last().click(); + await page.waitForTimeout(900); + const sheet = await page.locator('body').innerText(); + console.log( + `[sheet] Galerie=${sheet.includes('Foto oder Video wählen')} ` + + `Kamera=${sheet.includes('Jetzt aufnehmen')}\n` + ); + // Close it again. The FAB TOGGLES, so leaving the sheet open here makes the loop's first + // "open the sheet" click close it instead — and the file chooser then never fires. + await page.getByRole('button', { name: /Abbrechen/i }).first().click(); + await page.waitForTimeout(600); + + const results = []; + for (const path of files) { + const name = basename(path); + const local = await readFile(path); + const size = (await stat(path)).size; + const caption = `integrity ${name} ${Date.now()}`; + const chunked = size > CHUNK_BYTES; + + // Open the sheet fresh for every file — submitting returns to the feed, and the + // invariant this relies on is simply that the sheet is CLOSED at the top of each pass. + await page.locator('button[aria-label="Hochladen"]').last().click(); + await page.waitForTimeout(900); + const chooser = page.waitForEvent('filechooser'); + await page.getByText('Foto oder Video wählen').click(); + (await chooser).setFiles(path); + await page.waitForTimeout(2500); + await page.locator('textarea').fill(caption); + await page.getByRole('button', { name: /^Hochladen$/ }).first().click(); + // Generous: the queue retries, and a large file on a throttled box takes its time. + await page.waitForTimeout(Math.max(9000, (size / 1e6) * 900)); + + // Find it server-side. The feed is the guest's own view, so this also proves the photo + // is actually visible rather than merely stored. + const feed = await fetch(`${API}/feed?limit=50`, { + headers: { Authorization: `Bearer ${token}` }, + }).then((r) => r.json()); + const row = feed.uploads?.find((u) => u.caption === caption); + if (!row) { + results.push({ name, size, chunked, ok: false, why: 'never appeared in the feed' }); + continue; + } + // The whole point: compare the bytes that came BACK, not the status that went out. + const served = Buffer.from( + await fetch(`${API}/upload/${row.id}/original`).then((r) => r.arrayBuffer()) + ); + const ok = served.length === size && sha(served) === sha(local); + results.push({ + name, + size, + chunked, + ok, + why: ok ? '' : `stored ${served.length} of ${size} bytes / hash mismatch`, + }); + } + + await browser.close(); + + console.log('═'.repeat(66)); + console.log('UPLOAD INTEGRITY'); + console.log('═'.repeat(66)); + for (const r of results) { + console.log( + ` ${r.ok ? '✓' : '✗'} ${r.name.padEnd(24)} ${(r.size / 1e6).toFixed(1).padStart(6)} MB ` + + `${r.chunked ? 'chunked copy' : 'single read '} ${r.why}` + ); + } + if (!results.some((r) => r.chunked)) + console.log('\n ⚠ no file above 4 MB — the chunked copy path was NOT exercised'); + if (pageErrors.length) console.log(`\n page errors: ${pageErrors[0]}`); + const failed = results.filter((r) => !r.ok).length; + console.log(`\n${failed ? `✗ ${failed} of ${results.length} corrupted` : `✓ all ${results.length} byte-identical`}`); + console.log('═'.repeat(66)); + process.exit(failed ? 1 : 0); +} + +main().catch((e) => { + console.error('integrity check failed:', e); + process.exit(1); +});