From 164c7d2aa38c89c4ff5f400eb593c745da0ef755 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 23 Aug 2026 16:09:21 +0200 Subject: [PATCH] test(upload): prove the stored bytes are the guest's bytes, not just that a 201 came back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.18.3 through v0.18.6 were one failure in four disguises: the browser handing the queue something that was not the file. An empty body first (WebKit stores a picked `File` as a reference to an OS file iOS then deletes), and once the bytes were copied, the risk of a SHORT one — that copy is a multi-second chunked loop for a video, and the same purge can land partway through it. Every check that missed them shared one shortcut: asserting 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, appears in the gallery — and is still not the guest's video. Acceptance was never the question. So this drives the real composer through the real file chooser, then fetches the stored original back and compares its SHA-256 to the file on disk. It also names the two copy paths, because they are different code and only one of them can produce a short blob: at or below 4 MB a single `arrayBuffer()`, above it the chunked loop. A run with no file over 4 MB says so rather than reporting a pass it did not earn. Replaces the two throwaway scripts these hotfixes were verified with. Reports which entries the upload sheet offers, so a run also records whether PUBLIC_CAMERA_ENABLED was in effect. Verified against v0.18.6: 32 KB single-read and 14.8 MB chunked, both byte-identical. Co-Authored-By: Claude Opus 5 (1M context) --- e2e/loadtest/upload-integrity-check.mjs | 150 ++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 e2e/loadtest/upload-integrity-check.mjs 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); +});