Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
164c7d2aa3 | ||
| c8795ddfac |
150
e2e/loadtest/upload-integrity-check.mjs
Normal file
150
e2e/loadtest/upload-integrity-check.mjs
Normal file
@@ -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> [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);
|
||||||
|
});
|
||||||
@@ -889,7 +889,7 @@ export async function releaseResolvedParks(state: {
|
|||||||
|
|
||||||
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
|
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
|
||||||
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
|
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
|
||||||
export type EnqueueResult = 'queued' | 'duplicate' | 'full';
|
export type EnqueueResult = 'queued' | 'duplicate' | 'full' | 'unreadable';
|
||||||
|
|
||||||
/** Chunk size for `materialise`. Bounds peak JS heap, not total copy size. */
|
/** Chunk size for `materialise`. Bounds peak JS heap, not total copy size. */
|
||||||
const MATERIALISE_CHUNK_BYTES = 4 * 1024 * 1024;
|
const MATERIALISE_CHUNK_BYTES = 4 * 1024 * 1024;
|
||||||
@@ -907,15 +907,30 @@ const MATERIALISE_CHUNK_BYTES = 4 * 1024 * 1024;
|
|||||||
* can spill them to disk, which is exactly where a half-gigabyte video should live.
|
* can spill them to disk, which is exactly where a half-gigabyte video should live.
|
||||||
*/
|
*/
|
||||||
async function materialise(file: File): Promise<Blob> {
|
async function materialise(file: File): Promise<Blob> {
|
||||||
|
let out: Blob;
|
||||||
if (file.size <= MATERIALISE_CHUNK_BYTES) {
|
if (file.size <= MATERIALISE_CHUNK_BYTES) {
|
||||||
return new Blob([await file.arrayBuffer()], { type: file.type });
|
out = new Blob([await file.arrayBuffer()], { type: file.type });
|
||||||
|
} else {
|
||||||
|
const parts: Blob[] = [];
|
||||||
|
for (let offset = 0; offset < file.size; offset += MATERIALISE_CHUNK_BYTES) {
|
||||||
|
const slice = file.slice(offset, offset + MATERIALISE_CHUNK_BYTES);
|
||||||
|
parts.push(new Blob([await slice.arrayBuffer()]));
|
||||||
|
}
|
||||||
|
out = new Blob(parts, { type: file.type });
|
||||||
}
|
}
|
||||||
const parts: Blob[] = [];
|
// Verify the copy. The purge this whole function exists to defeat can also land PARTWAY
|
||||||
for (let offset = 0; offset < file.size; offset += MATERIALISE_CHUNK_BYTES) {
|
// THROUGH the loop above: a 500 MB video is many seconds of reading, and once the OS file
|
||||||
const slice = file.slice(offset, offset + MATERIALISE_CHUNK_BYTES);
|
// is gone the remaining slices read as nothing. `new Blob` is happy to build a short blob
|
||||||
parts.push(new Blob([await slice.arrayBuffer()]));
|
// out of them, and short is far worse than absent — it uploads, the server stores it, and
|
||||||
|
// the guest gets a truncated video that looks like it worked. A read that returns fewer
|
||||||
|
// bytes than the file claims is never legitimate, so refuse it here, while the guest is
|
||||||
|
// still holding the phone and can pick the file again.
|
||||||
|
if (out.size !== file.size) {
|
||||||
|
throw new UnreadableBlobError(
|
||||||
|
'Diese Datei konnte nicht vollständig gelesen werden — bitte wähle sie noch einmal aus.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return new Blob(parts, { type: file.type });
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addToQueue(
|
export async function addToQueue(
|
||||||
@@ -980,7 +995,15 @@ export async function addToQueue(
|
|||||||
// Reading the file here makes IndexedDB own real bytes that no OS purge can reach. It
|
// Reading the file here makes IndexedDB own real bytes that no OS purge can reach. It
|
||||||
// costs one full read at pick time, which is also the moment the file is guaranteed still
|
// costs one full read at pick time, which is also the moment the file is guaranteed still
|
||||||
// readable — the picker has only just handed it over.
|
// readable — the picker has only just handed it over.
|
||||||
const blob = await materialise(file);
|
// A file the browser cannot fully read is not a queueable item. Returning a result rather
|
||||||
|
// than throwing keeps the composer's per-file loop intact: one bad photo out of five must
|
||||||
|
// not abandon the other four, which is what an exception here would do.
|
||||||
|
let blob: Blob;
|
||||||
|
try {
|
||||||
|
blob = await materialise(file);
|
||||||
|
} catch {
|
||||||
|
return 'unreadable';
|
||||||
|
}
|
||||||
const entry: QueueEntry = {
|
const entry: QueueEntry = {
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -163,6 +163,16 @@
|
|||||||
}
|
}
|
||||||
const result = await addToQueue(sf.file, caption, hashtagsString);
|
const result = await addToQueue(sf.file, caption, hashtagsString);
|
||||||
if (result === 'full') full++;
|
if (result === 'full') full++;
|
||||||
|
// The browser could not read this file's bytes (iOS purges the OS file behind a
|
||||||
|
// picked photo). Named per file rather than counted like `full`: the guest has to
|
||||||
|
// find and re-pick this specific one, so a bare number would not be actionable.
|
||||||
|
if (result === 'unreadable') {
|
||||||
|
toast(
|
||||||
|
`„${sf.file.name}“ konnte nicht gelesen werden. Bitte wähle das Foto noch einmal aus.`,
|
||||||
|
'error',
|
||||||
|
8000
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Don't let a full queue silently swallow photos the user thinks were queued.
|
// Don't let a full queue silently swallow photos the user thinks were queued.
|
||||||
if (full > 0) {
|
if (full > 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user