monal-rebuild/ux-media-runner.cjs
pijukka 73d7463f94 test(chat): confirm the picker fix and harden the group check
The operator confirms the attachment button works on their iPhone after
the switch to generation 558, which is the only evidence that could settle
the user-activation diagnosis: the new MEDIA-PICKER-OPENS check cannot,
since a control run with the await restored passes in both Chromium and
Linux WebKit.

The full suite re-ran against the deployed bundle and all eleven checks
pass. MEDIA-GROUP-PLAINTEXT failed on the first re-run for a reason in the
runner rather than the client: chat(room) waits for an enabled composer,
which the conversation being navigated away from already satisfies, so the
attach landed before the room was joined and the client correctly refused
the upload. It now waits for the header to show participants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 18:11:25 +02:00

444 lines
23 KiB
JavaScript

// Candidate checks for package 9, encrypted attachments (XEP-0454). Runs
// against the deployed bundle with the dedicated audit accounts; profiles,
// credentials and reports stay outside Git. One engine at a time.
//
// What it can and cannot prove: two Linux browser profiles exercise the
// whole wire format, but a round trip against real Monal and iPhone
// recording/playback are separate, device-bound checks (OMEMO-PLAN.md).
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const {client, xml} = require('@xmpp/client');
const engine = process.argv[2] || 'chromium';
if (engine === 'webkit') {
process.env.UX_WEBKIT_PATH ??= '/nix/store/kvbchbp55jd09wh2h1bv3xx9cz26k6vg-playwright-webkit/pw_run.sh';
process.env.LIBGL_ALWAYS_SOFTWARE ??= '1'; process.env.EGL_PLATFORM ??= 'surfaceless';
process.env.LIBGL_DRIVERS_PATH ??= '/nix/store/ppphyhp48gzdji3q9q945gmamy4rrj3f-mesa-26.2.2/lib/dri';
process.env.__EGL_VENDOR_LIBRARY_FILENAMES ??= '/nix/store/ppphyhp48gzdji3q9q945gmamy4rrj3f-mesa-26.2.2/share/glvnd/egl_vendor.d/50_mesa.json';
}
const pw = require(process.env.UX_PLAYWRIGHT_MODULE || (engine === 'webkit' ? '/tmp/chat-ux-webkit/node_modules/playwright' : '/tmp/chat-ux-runner/node_modules/playwright'));
const accounts = JSON.parse(fs.readFileSync(process.env.UX_ACCOUNTS_FILE || '/tmp/chat-ux-runner/accounts.json', 'utf8'));
const option = name => process.argv.find(arg => arg.startsWith('--' + name + '='))?.slice(name.length + 3);
const checks = process.env.UX_CHECKS || option('checks');
const origin = process.env.UX_BASE_URL || option('origin') || 'https://chat.0ff.space';
const profile = process.env.UX_PROFILE_DIR || `/tmp/chat-ux-runner/media-profiles/${engine}/a`;
const out = process.env.UX_RESULTS_DIR || `/tmp/chat-ux-runner/results/media-${engine}-${Date.now()}`;
fs.mkdirSync(out, {recursive: true, mode: 0o700});
const report = {engine, origin, checks: [], errors: [], simulation: 'Two Linux browser profiles against the deployed bundle, real IndexedDB, service worker and audit XMPP accounts. Not a Monal round trip and not iPhone media hardware.'};
const CHROMIUM = process.env.UX_CHROMIUM_PATH || '/nix/store/33pxss8h71cl7vmfpy21bidsw0lj1g8q-chromium-152.0.7977.82/bin/chromium';
// Observed on the wire and on the upload host, for the assertions below.
const wire = {messages: [], slotRequests: []};
const puts = [];
const foreignRequests = [];
let holdPut = false, putGate;
let corruptGet = false, missingGet = false;
const delay = ms => new Promise(r => setTimeout(r, ms));
async function until(fn, timeout = 20000) {
const end = Date.now() + timeout;
do { if (await fn()) return; await delay(100); } while (Date.now() < end);
throw Error('Condition timed out');
}
function save() { fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2), {mode: 0o600}); }
const redact = s => String(s).replaceAll(accounts[0].password, '[redacted]').replaceAll(accounts[1].password, '[redacted]');
let ctx, page, bctx, b;
async function context(dir, extra = {}) {
return pw[engine].launchPersistentContext(dir, {
headless: true, viewport: {width: 402, height: 874}, isMobile: true, hasTouch: true,
executablePath: engine === 'chromium' ? CHROMIUM : process.env.UX_WEBKIT_PATH,
// WebKit has no microphone permission name; only Chromium grants it.
...(engine === 'chromium' ? {permissions: ['microphone']} : {}),
...(engine === 'chromium' ? {args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream']} : {}),
...extra,
});
}
// The upload host is a different origin, so routing it catches every PUT and
// GET without touching the websocket.
async function routeUploads(context) {
// Registered first on purpose: Playwright runs the most recently added
// matching handler first, so the upload-specific route below must come
// after this catch-all or the PUTs would never be seen here.
await context.route('https://**', async route => {
const url = route.request().url();
if (url.startsWith(origin) || url.startsWith('https://upload.0ff.space')) return route.continue();
foreignRequests.push(url);
return route.abort();
});
await context.route('https://upload.0ff.space/**', async route => {
const request = route.request();
if (request.method() === 'PUT') {
const body = request.postDataBuffer();
puts.push({url: request.url(), type: request.headers()['content-type'], bytes: body, size: body?.length});
if (holdPut) await putGate;
// The client may have aborted the XHR while the gate was held.
return route.continue().catch(() => {});
}
if (missingGet) return route.fulfill({status: 404, body: 'gone'});
if (corruptGet) {
const response = await route.fetch();
const buffer = Buffer.from(await response.body());
buffer[0] ^= 0x01; // one flipped byte: the GCM tag must reject it
// Without this the corrupted body inherits Prosody's immutable caching
// and the retry below would just read it back out of the cache.
return route.fulfill({response, body: buffer, headers: {...response.headers(), 'cache-control': 'no-store'}});
}
return route.continue();
});
}
async function launch() {
ctx = await context(profile);
await routeUploads(ctx);
await ctx.routeWebSocket('**/xmpp-websocket', ws => {
const server = ws.connectToServer();
ws.onMessage(message => {
const s = String(message);
// Never trimmed: the checks index into this by send order.
if (s.startsWith('<message')) wire.messages.push(s);
if (s.includes('urn:xmpp:http:upload:0') && s.includes('<request')) wire.slotRequests.push(s);
server.send(message);
});
server.onMessage(m => ws.send(m));
});
page = ctx.pages()[0] || await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', e => { report.errors.push(redact(e.message)); (report.errorDetails ??= []).push({source: 'pageerror', stack: redact(e.stack)}); });
page.on('requestfailed', r => { (report.networkFailures ??= []).push({url: r.url(), type: r.resourceType(), error: r.failure()?.errorText}); });
}
async function login(p = page, account = accounts[0]) {
await p.goto(origin);
let submitted = false;
await until(async () => {
if (!submitted && await p.locator('[name=username]').isVisible()) {
submitted = true;
await p.locator('[name=username]').fill(account.jid);
await p.locator('[name=password]').fill(account.password);
await p.getByRole('checkbox', {name: 'Stay logged in'}).check();
await p.getByRole('button', {name: 'Log in', exact: true}).click();
}
return await p.locator('.app').count() > 0 && !await p.locator('.connection').count();
}, 30000);
}
async function chat(jid = accounts[1].jid, p = page) {
await p.evaluate(j => location.hash = '#/chat/' + encodeURIComponent(j), jid);
await p.locator('.composer textarea:enabled').waitFor();
}
async function check(id, fn) {
if (checks && !checks.split(',').includes(id)) return;
try { report.checks.push({id, status: 'passed', ...await fn()}); }
catch (e) {
report.checks.push({id, status: 'failed', detail: redact(e.message)});
await page.screenshot({path: path.join(out, id + '.png')}).catch(() => {});
}
save();
console.log(JSON.stringify(report.checks.at(-1)));
}
// --- fixtures ---------------------------------------------------------------
// A real 1x1 PNG, so an <img> genuinely decodes what came back decrypted.
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', 'base64');
const DOC = Buffer.from('audit fixture, package 9, not secret\n'.repeat(8));
const rowFor = (p, selector) => p.locator('.row').filter({has: p.locator(selector)});
const lastBubble = p => p.locator('.bubble').last();
async function attach(p, name, mime, bytes) {
await p.locator('input[type=file]').setInputFiles({name, mimeType: mime, buffer: bytes});
}
/** The outgoing stanza that carried this send, by send order. */
function outgoing(since) {
return wire.messages.slice(since).filter(s => s.includes('<body>'));
}
function assertEnvelopeOnly(stanzas, label) {
assert.ok(stanzas.length >= 1, label + ': no message went out');
for (const s of stanzas) {
assert.ok(s.includes('eu.siacs.conversations.axolotl'), label + ': stanza is not OMEMO-encrypted');
assert.ok(!/jabber:x:oob/.test(s), label + ': stanza carries an oob child');
assert.ok(!/aesgcm:/i.test(s), label + ': the aesgcm URL appears in cleartext XML');
}
}
// --- checks -----------------------------------------------------------------
async function main() {
await launch();
await login();
await chat();
await check('MEDIA-CAPABILITY', async () => {
// The peer publishes devices from the earlier audits, so this chat is the
// encrypted case. Before package 9 the attachment entry was disabled with
// "Encrypted files and voice messages aren't supported yet."
await until(async () => await page.locator('.lock, [aria-label="End-to-end encrypted"]').count() > 0, 30000);
const picker = page.getByRole('button', {name: 'Attach a file', exact: true});
await until(async () => await picker.isEnabled());
const text = await page.locator('.composer').innerText();
assert.ok(!/aren.t supported yet/i.test(text), 'the old limitation text is still shown');
return {encryptedChat: true, attachmentEntryEnabled: true, limitationTextGone: true};
});
await check('MEDIA-PICKER-OPENS', async () => {
// The path every other check skips by setting files on the input
// directly: pressing the button must actually open the chooser.
//
// It does NOT guard the iOS restriction this path was fixed for. A
// control run with the await restored passes here too, so neither
// Chromium nor Linux WebKit enforces Safari's user-activation window for
// a programmatic input click. Only the operator's iPhone can show that.
const [chooser] = await Promise.all([
page.waitForEvent('filechooser', {timeout: 10000}),
page.getByRole('button', {name: 'Attach a file', exact: true}).click(),
]);
await chooser.setFiles([]);
return {fileChooserOpenedFromTheButton: true};
});
await check('MEDIA-IMAGE-ROUNDTRIP', async () => {
bctx = await context(path.join(path.dirname(profile), 'b'));
await routeUploads(bctx);
b = await bctx.newPage();
b.setDefaultTimeout(15000);
await login(b, accounts[1]);
await chat(accounts[0].jid, b);
const since = wire.messages.length, slots = wire.slotRequests.length, putCount = puts.length;
await attach(page, 'Holiday in Rome.png', 'image/png', PNG);
// The sender's own bubble renders from a decrypted blob, not from the URL.
// Counted, not just waited for: the profile is persistent, so an earlier
// run's attachment is already on screen and a bare waitFor would pass
// without anything having been sent.
const mine = page.locator('.bubble img[src^="blob:"]');
const mineBefore = await mine.count();
await until(async () => await mine.count() > mineBefore, 30000);
assertEnvelopeOnly(outgoing(since), 'image');
const slot = wire.slotRequests.slice(slots).at(-1);
assert.ok(slot, 'no upload slot was requested');
assert.match(slot, /content-type="application\/octet-stream"/, 'the real media type was declared');
assert.match(slot, /filename="[0-9a-f-]{36}\.png"/, 'the real file name was declared');
assert.ok(!/Holiday/i.test(slot), 'the user file name reached the server');
const put = puts.slice(putCount).at(-1);
assert.ok(put, 'no PUT was made');
assert.equal(put.type, 'application/octet-stream');
assert.ok(!put.bytes.equals(PNG), 'the plaintext file was uploaded');
assert.equal(put.size, PNG.length + 16, 'the upload is not plaintext+tag sized');
assert.equal(put.bytes.subarray(0, 8).toString('hex') === PNG.subarray(0, 8).toString('hex'), false, 'the PNG header is visible in the upload');
// The receiving profile decrypts it independently.
const theirs = b.locator('.bubble img[src^="blob:"]');
await until(async () => await theirs.count() > 0, 30000);
const decoded = await theirs.last().evaluate(el => ({w: el.naturalWidth, h: el.naturalHeight}));
assert.deepEqual(decoded, {w: 1, h: 1}, 'the decrypted image did not decode');
// And the chat list shows a label, never a key-bearing URL.
await b.evaluate(() => location.hash = '');
const preview = await b.locator('.preview').first().innerText();
assert.ok(!/aesgcm|#/.test(preview), 'the preview leaks the attachment URL: ' + preview);
assert.match(preview, /Photo/);
await chat(accounts[0].jid, b);
return {senderRendersBlob: true, receiverDecrypted: true, uploadIsCiphertext: true, nameAndTypeAnonymised: true, previewLabelled: preview.trim()};
});
await check('MEDIA-FILE-ROUNDTRIP', async () => {
const since = wire.messages.length, putCount = puts.length;
await attach(page, 'quarterly.bin', 'application/octet-stream', DOC);
const link = b.locator('.bubble a.file[href^="blob:"]');
const linksBefore = await link.count();
await until(async () => await link.count() > linksBefore, 30000);
assertEnvelopeOnly(outgoing(since), 'file');
const put = puts.slice(putCount).at(-1);
assert.ok(!put.bytes.equals(DOC), 'the plaintext file was uploaded');
assert.ok(!put.bytes.includes(Buffer.from('audit fixture')), 'plaintext is readable in the upload');
const label = await link.last().innerText();
assert.match(label, /\.bin$/);
assert.ok(!/quarterly/i.test(label), 'the original name survived to the receiver');
// What the receiver saves must be the decrypted bytes. Checked through a
// real download, not an in-page fetch of the blob URL: `connect-src` does
// not list blob:, and the app never fetches its own blob either — it only
// assigns it to src/href.
const [download] = await Promise.all([b.waitForEvent('download', {timeout: 15000}), link.last().click()]);
const saved = await download.path();
assert.deepEqual(fs.readFileSync(saved), DOC, 'the downloaded file is not the plaintext');
assert.match(download.suggestedFilename(), /^[0-9a-f-]{36}\.bin$/);
return {receiverDecryptedFile: true, uploadIsCiphertext: true, savedLabel: label.trim(), downloadedPlaintextBytes: DOC.length};
});
await check('MEDIA-VOICE', async () => {
if (engine !== 'chromium') return {skipped: 'no fake audio device driver for this engine', recorded: false};
const since = wire.messages.length, putCount = puts.length;
await page.getByRole('button', {name: 'Record a voice message', exact: true}).click();
await page.getByRole('button', {name: 'Send voice message', exact: true}).waitFor();
await delay(1200);
await page.getByRole('button', {name: 'Send voice message', exact: true}).click();
const audio = b.locator('.bubble audio[src^="blob:"]');
const audioBefore = await audio.count();
await until(async () => await audio.count() > audioBefore, 30000);
assertEnvelopeOnly(outgoing(since), 'voice');
const put = puts.slice(putCount).at(-1);
assert.equal(put.type, 'application/octet-stream');
const slot = wire.slotRequests.at(-1);
assert.ok(!/voice-/.test(slot), 'the recording name reached the server');
return {recorded: true, receiverDecryptedAudio: true, note: 'Synthetic Chromium audio device; real iPhone recording and playback remain a device check.'};
});
await check('MEDIA-CANCEL', async () => {
const since = wire.messages.length, putCount = puts.length;
let release;
putGate = new Promise(r => release = r);
holdPut = true;
await attach(page, 'cancelled.bin', 'application/octet-stream', Buffer.alloc(256 * 1024, 7));
const upload = page.locator('.upload').last();
await upload.waitFor();
await upload.getByRole('button', {name: 'Cancel upload', exact: true}).click();
await until(async () => await page.locator('.upload').count() === 0);
holdPut = false; release();
await delay(1500);
assert.equal(outgoing(since).length, 0, 'a cancelled upload still sent a message');
assert.ok(puts.length - putCount <= 1, 'a cancelled upload made extra PUTs');
return {noMessageSent: true, uploadRowCleared: true, putsMade: puts.length - putCount};
});
// Each of the next two sends a *fresh* attachment on purpose. Prosody serves
// every upload with "cache-control: max-age=31556952, immutable", so an
// already-fetched ciphertext comes from the browser cache and a reload never
// reaches the network — correct behaviour in the app, but it would make a
// re-reading check assert nothing.
await check('MEDIA-GONE', async () => {
// The upload expires after 30 days on this server; a 404 must read as that
// and not as a decryption problem.
missingGet = true;
let shown;
try {
await attach(page, 'expired.png', 'image/png', PNG);
// Matched on the element, not with an exact string: the retry button sits
// inside the same span, so its label is part of the text content.
const problem = b.locator('.bubble').last().locator('.media-problem');
await until(async () => /No longer available on the server\./.test(await problem.innerText().catch(() => '')), 30000);
shown = (await problem.innerText()).trim();
assert.equal(await b.locator('.bubble').last().locator('img[src^="blob:"]').count(), 0, 'a missing file still rendered');
assert.ok(!/could not be decrypted/.test(shown), 'a 404 was reported as a decryption failure');
} finally { missingGet = false; }
return {expiredUploadExplained: true, distinctFromIntegrityFailure: true, shown};
});
await check('MEDIA-CORRUPT', async () => {
corruptGet = true;
let shown;
try {
await attach(page, 'flipped.png', 'image/png', PNG);
const latest = b.locator('.bubble').last();
const problem = latest.locator('.media-problem');
await until(async () => /could not be decrypted/.test(await problem.innerText().catch(() => '')), 30000);
shown = (await problem.innerText()).trim();
assert.equal(await latest.locator('img[src^="blob:"]').count(), 0, 'a corrupted file still rendered');
assert.ok(!/No longer available/.test(shown), 'an integrity failure was reported as a missing file');
assert.ok(await latest.getByRole('button', {name: 'Try again', exact: true}).isVisible());
// The same bytes, intact, decrypt on retry: the failure was the tag, not
// lost state. The corrupted response is fulfilled with no-store above, so
// this really goes back to the network.
corruptGet = false;
await latest.getByRole('button', {name: 'Try again', exact: true}).click();
await latest.locator('img[src^="blob:"]').waitFor({timeout: 30000});
} finally { corruptGet = false; }
return {integrityFailureShown: true, retryRecovers: true, shown};
});
await check('MEDIA-FOREIGN-HOST', async () => {
// A federated contact's attachment sits on their own server, which the CSP
// does not allow fetching. Seeded locally: no foreign account is involved.
const key = 'UX-media-foreign-' + Date.now();
const before = foreignRequests.length;
await page.evaluate(async ({me, peer, key}) => {
const db = await new Promise(res => { const r = indexedDB.open('chat:' + me); r.onsuccess = () => res(r.result); });
const tx = db.transaction('messages', 'readwrite');
tx.objectStore('messages').put({
key, peer, type: 'chat', dir: 'in', from: peer, ts: Date.now(), id: key, encrypted: true,
body: 'aesgcm://files.example.invalid/f/a.png#' + 'ab'.repeat(44),
});
await new Promise(res => tx.oncomplete = res);
db.close();
}, {me: accounts[0].jid, peer: accounts[1].jid, key});
try {
await page.reload();
await chat();
await page.getByText(/Encrypted files can only be opened from/).first().waitFor({timeout: 30000});
await delay(1000);
assert.deepEqual(foreignRequests.slice(before).filter(u => u.includes('files.example.invalid')), [], 'a request went to the foreign host');
} finally {
await page.evaluate(async ({me, key}) => {
const db = await new Promise(res => { const r = indexedDB.open('chat:' + me); r.onsuccess = () => res(r.result); });
const tx = db.transaction('messages', 'readwrite');
tx.objectStore('messages').delete(key);
await new Promise(res => tx.oncomplete = res);
db.close();
}, {me: accounts[0].jid, key}).catch(() => {});
}
return {explainedWithoutFetching: true};
});
await check('MEDIA-GROUP-PLAINTEXT', async () => {
// Rooms are not encrypted here, so they must keep the plaintext OOB path
// exactly as before — the regression this package could most easily cause.
const room = `ux-media-${engine}-${Date.now()}@muc.0ff.space`;
report.groupFixture = room;
await chat(room);
const join = page.getByRole('button', {name: 'Join', exact: true});
if (await join.count()) await join.click();
await page.locator('textarea[placeholder="Message"]').waitFor({timeout: 30000});
// Wait for the room to actually be joined, not just for a composer: the
// previous conversation's enabled textarea can still satisfy the waits
// above during the switch, and attaching then makes the client refuse the
// upload — correctly — with "Join the room before sending files".
await until(async () => /participant/.test(await page.locator('.title .sub').innerText().catch(() => '')), 30000);
const since = wire.messages.length, putCount = puts.length;
await attach(page, 'group.png', 'image/png', PNG);
const img = page.locator('.bubble img[src^="https://upload.0ff.space/"]');
await img.waitFor({timeout: 30000});
const stanzas = wire.messages.slice(since).filter(s => s.includes('<body>'));
assert.ok(stanzas.some(s => /jabber:x:oob/.test(s)), 'the group upload lost its oob child');
assert.ok(!stanzas.some(s => /aesgcm:/i.test(s)), 'a group upload was shared as aesgcm');
const put = puts.slice(putCount).at(-1);
assert.ok(put.bytes.equals(PNG), 'the group upload was altered');
assert.equal(put.type, 'image/png', 'the group upload lost its real media type');
assert.match(wire.slotRequests.at(-1), /filename="group\.png"/);
return {plaintextOobPathIntact: true, realNameAndTypeKept: true};
});
await check('MEDIA-NO-STRAY-REQUESTS', async () => {
assert.deepEqual(foreignRequests, [], 'the client fetched a host outside its own origin and upload host');
const plaintextPuts = puts.filter(p => p.bytes && (p.bytes.equals(PNG) || p.bytes.equals(DOC)));
// Exactly one: the group upload, which is plaintext by design.
assert.equal(plaintextPuts.length, 1, 'plaintext PUTs: ' + plaintextPuts.length);
assert.equal(plaintextPuts[0].type, 'image/png');
report.uploads = puts.map(p => ({type: p.type, size: p.size}));
return {totalPuts: puts.length, plaintextPuts: 1, plaintextPutIsTheGroupOne: true};
});
}
main()
.catch(e => {
report.errors.push(redact(e.message));
(report.errorDetails ??= []).push({source: 'runner', stack: redact(e.stack)});
})
.finally(async () => {
if (report.groupFixture) {
const xmpp = client({service: 'wss://chat.0ff.space/xmpp-websocket', domain: '0ff.space', username: accounts[0].id, password: accounts[0].password, resource: 'ux-media-cleanup-' + Date.now()});
xmpp.on('error', () => {});
try {
await xmpp.start();
await xmpp.iqCaller.set(xml('query', {xmlns: 'http://jabber.org/protocol/muc#owner'}, xml('destroy')), report.groupFixture, 10000);
report.groupFixtureDestroyed = true;
} catch (e) { report.groupFixtureDestroyed = redact(e.condition || e.message); }
finally { await xmpp.stop().catch(() => {}); }
}
await bctx?.close();
await ctx?.close();
save();
console.log(JSON.stringify({report: path.join(out, 'report.json'), errors: report.errors, checks: report.checks.map(c => ({id: c.id, status: c.status}))}));
if (report.errors.length || report.checks.some(c => c.status === 'failed')) process.exitCode = 1;
});