pediatric-ai-scribe-v3/test/assistant-sharing-boundary.test.js
Daniel 40e7dd5206
All checks were successful
Forgejo Android APK / Root app tests (push) Successful in 31s
Forgejo Android APK / Build signed APK (push) Successful in 1m57s
fix: bind clinical image and export actions to their original account
2026-09-07 07:45:48 +02:00

297 lines
15 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const read = file => fs.readFileSync(path.join(__dirname, '..', 'public/js', file), 'utf8');
const tick = () => new Promise(resolve => setImmediate(resolve));
const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; };
const image = 'data:image/png;base64,c3ludGhldGlj';
const remote = 'https://example.test/synthetic.png';
function ui(t, { native = false, inline = true } = {}) {
const dom = new JSDOM('<body><div id="unrelated">unrelated feature</div></body>', { url: 'https://example.test', runScripts: 'outside-only' });
const w = dom.window;
// Real boundary code, synthetic storage/identity, no auth/network services.
w.eval(read('accountBoundary.js'));
assert.equal(w.AccountBoundary.enter({ id: 'synthetic-A' }, true), true);
const calls = { native: [], writes: [], shares: [], web: [], anchors: [], toasts: [], prints: [], fetches: [], urls: [] };
const timers = [];
w.matchMedia = () => ({ matches: inline });
w.showToast = (...args) => calls.toasts.push(args);
w.print = () => calls.prints.push('browser');
if (native) w.Capacitor = { isNativePlatform: () => true, Plugins: {} };
w.URL.createObjectURL = () => { calls.urls.push('create'); return 'blob:synthetic'; };
w.URL.revokeObjectURL = () => calls.urls.push('revoke');
w.HTMLAnchorElement.prototype.click = function() { calls.anchors.push(this.download); };
w.fetch = async (url, options) => { calls.fetches.push({ url, options }); return { ok: true, blob: async () => new w.Blob(['synthetic'], { type: 'image/png' }) }; };
w.setTimeout = fn => { timers.push(fn); return timers.length; };
w.clearTimeout = id => { timers[id - 1] = () => {}; };
for (const file of ['assistant/citations.js', 'assistant/sharing.js', 'assistant/images.js', 'assistant/export.js']) {
if (file.endsWith('sharing.js') && !fs.existsSync(path.join(__dirname, '..', 'public/js', file))) continue;
vm.runInContext(read(file).replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), dom.getInternalVMContext());
}
const store = w.createAssistantImageStore();
const exporter = w.createAssistantExporter({ showToast: w.showToast });
function freezeAndReplace() {
const original = w.AccountBoundary;
original.freeze();
// A real document cannot re-enter. A replacement object models a fresh B
// becoming visible to stale callbacks; they must retain A's boundary.
const b = new JSDOM('<body></body>', { url: 'https://example.test', runScripts: 'outside-only' });
b.window.eval(read('accountBoundary.js'));
assert.equal(b.window.AccountBoundary.enter({ id: 'synthetic-B' }, true), true);
w.AccountBoundary = b.window.AccountBoundary;
t.after(() => b.window.close());
}
function download(src = image, url = '') {
const html = store.renderGeneratedImage(src, 'synthetic', url);
return store.downloadImage(html.match(/data-assistant-download-image="([^"]+)"/)[1]);
}
function exported(answer = 'Synthetic answer A') {
exporter.exportAnswerPdf({ lastAnswer: answer, lastGeneratedImageSrc: image });
return w.document.querySelector('#assistant-export-modal');
}
function plugins() {
return { Filesystem: { writeFile: async args => { calls.writes.push(args); return { uri: 'file://' + args.path }; } },
Share: { share: async args => { calls.shares.push(args); } } };
}
t.after(() => w.close());
return { w, calls, timers, store, exporter, freezeAndReplace, download, exported, plugins };
}
for (const stage of ['fetch', 'blob', 'reader']) {
for (const server of [false, true]) {
test(`image ${server ? 'server' : 'legacy'} ${stage} continuation rejects A -> freeze -> B`, async t => {
const app = ui(t, { native: true });
const pending = deferred();
app.w.Capacitor.Plugins = app.plugins();
app.w.NativeFiles = { saveImage: (...args) => { app.calls.native.push(args); return 'saved:ok'; } };
if (stage === 'fetch') app.w.fetch = () => pending.promise;
if (stage === 'blob') app.w.fetch = async () => ({ ok: true, blob: () => pending.promise });
if (stage === 'reader') app.w.FileReader = class { readAsDataURL() { pending.promise.then(() => { this.result = image; this.onload(); }); } };
const job = app.download(remote, server ? '/api/synthetic-download' : '');
await tick();
app.freezeAndReplace();
pending.resolve(stage === 'fetch' ? { ok: true, blob: async () => new app.w.Blob(['synthetic']) } : new app.w.Blob(['synthetic']));
await job;
assert.deepEqual(app.calls.native, []);
assert.deepEqual(app.calls.writes, []);
assert.deepEqual(app.calls.shares, []);
assert.deepEqual(app.calls.toasts, []);
});
}
}
test('NativeFiles failure cannot recapture B for Share-only fallback', async t => {
const app = ui(t, { native: true });
app.w.NativeFiles = { saveImage() { app.freezeAndReplace(); throw new Error('bridge unavailable'); } };
app.w.Capacitor.Plugins = { Share: app.plugins().Share };
await app.download(remote);
assert.deepEqual(app.calls.shares, []);
assert.deepEqual(app.calls.toasts, []);
});
for (const server of [false, true]) {
test(`late ${server ? 'server' : 'legacy'} Filesystem write cannot share/toast or overwrite B's filename`, async t => {
const app = ui(t, { native: true });
const pending = deferred();
app.w.Capacitor.Plugins = app.plugins();
const paths = [];
app.w.Capacitor.Plugins.Filesystem.writeFile = args => { paths.push(args.path); return paths.length === 1 ? pending.promise : Promise.resolve({ uri: 'file://' + args.path }); };
const job = app.download(image, server ? '/api/synthetic-download' : '');
for (let i = 0; i < 100 && !paths.length; i++) await tick();
assert.equal(paths.length, 1, 'first write was admitted');
app.freezeAndReplace();
await app.download(image, server ? '/api/synthetic-download' : '');
const admitted = app.calls.shares.length;
app.calls.toasts.length = 0;
pending.resolve({ uri: 'file://' + paths[0] });
await job;
assert.equal(app.calls.shares.length, admitted);
assert.deepEqual(app.calls.toasts, []);
assert.notEqual(paths[0], paths[1]);
});
}
for (const mode of ['web conversion', 'web reject', 'browser conversion', 'share-only reject']) {
test(`${mode} cannot fall through to effects/toasts under B`, async t => {
const app = ui(t, { native: mode === 'share-only reject' });
const pending = deferred();
if (mode.startsWith('web')) {
app.w.navigator.canShare = () => true;
app.w.navigator.share = args => { app.calls.web.push(args); return mode === 'web reject' ? pending.promise : Promise.resolve(); };
}
if (mode.endsWith('conversion')) app.w.fetch = async () => ({ ok: true, blob: () => pending.promise });
if (mode === 'share-only reject') app.w.Capacitor.Plugins.Share = { share: args => { app.calls.shares.push(args); return pending.promise; } };
const job = app.download(remote);
await tick();
app.freezeAndReplace();
if (mode.endsWith('reject')) pending.reject(new Error('capability rejected'));
else pending.resolve(new app.w.Blob(['synthetic']));
await job;
assert.deepEqual(app.calls.anchors, []);
assert.deepEqual(app.calls.urls, []);
assert.deepEqual(app.calls.toasts, []);
assert.equal(app.calls.web.length, mode === 'web reject' ? 1 : 0);
assert.equal(app.calls.shares.length, mode === 'share-only reject' ? 1 : 0);
});
}
test('an active-owner policy AbortError is not a capability fallback', async t => {
const app = ui(t, { native: true });
app.w.NativeFiles = { saveImage() { throw new app.w.DOMException('Synthetic policy abort', 'AbortError'); } };
app.w.Capacitor.Plugins = app.plugins();
await app.download(image);
assert.deepEqual(app.calls.writes, []);
assert.deepEqual(app.calls.toasts, []);
});
test('download and export fail closed when boundary is absent, incomplete or inactive', async t => {
for (const boundary of [undefined, {}, { capture: () => 'A' }]) {
const app = ui(t, { native: true });
app.w.AccountBoundary = boundary;
app.w.Capacitor.Plugins = app.plugins();
await app.download();
assert.equal(app.exported(), null);
assert.deepEqual(app.calls.writes, []);
assert.deepEqual(app.calls.toasts, []);
}
const app = ui(t, { native: true });
app.w.AccountBoundary.freeze();
await app.download();
assert.equal(app.exported(), null);
});
for (const mode of ['native', 'filesystem', 'web', 'browser', 'share-only', 'native fallback', 'server native', 'server filesystem', 'server web', 'server browser']) {
test(`same-owner image succeeds: ${mode}`, async t => {
const app = ui(t, { native: /native|filesystem|share-only/.test(mode) });
if (/native/.test(mode)) app.w.NativeFiles = { saveImage: (...args) => { app.calls.native.push(args); return mode === 'native fallback' ? 'error:unavailable' : 'saved:ok'; } };
if (/filesystem|fallback/.test(mode)) app.w.Capacitor.Plugins = app.plugins();
if (mode === 'share-only') app.w.Capacitor.Plugins = { Share: app.plugins().Share };
if (/web/.test(mode)) { app.w.navigator.canShare = () => true; app.w.navigator.share = async args => { app.calls.web.push(args); }; }
await app.download(mode === 'share-only' ? remote : image, mode.startsWith('server') ? '/api/synthetic-download' : '');
assert.equal(app.calls.native.length + app.calls.shares.length + app.calls.web.length + app.calls.anchors.length, mode === 'native fallback' ? 2 : 1);
if (mode.startsWith('server')) {
assert.equal(app.calls.fetches[0].options.credentials, 'same-origin');
assert.equal(app.calls.fetches[0].options.signal, app.w.AccountBoundary.signal());
}
});
}
test('inline Print retains original export owner and closes only its preview on freeze', async t => {
const app = ui(t, { native: true });
app.w.NativePrint = { printHtml: (...args) => app.calls.prints.push(args) };
app.w.Capacitor.Plugins = app.plugins();
const modal = app.exported();
const print = modal.querySelector('#assistant-export-print');
app.freezeAndReplace();
assert.equal(modal.isConnected, false);
const newer = app.exported('Synthetic answer B');
print.click();
await tick();
assert.deepEqual(app.calls.prints, []);
assert.deepEqual(app.calls.writes, []);
assert.deepEqual(app.calls.toasts, []);
assert.equal(newer.isConnected, true);
assert.ok(app.w.document.querySelector('#unrelated'));
});
test('late inline Filesystem completion has no Share, print or toast and uses a distinct filename', async t => {
const app = ui(t, { native: true });
const pending = deferred();
app.w.Capacitor.Plugins = app.plugins();
app.w.Capacitor.Plugins.Filesystem.writeFile = args => { app.calls.writes.push(args); return app.calls.writes.length === 1 ? pending.promise : Promise.resolve({ uri: 'file://' + args.path }); };
const old = app.exported();
old.querySelector('#assistant-export-print').click();
await tick();
app.freezeAndReplace();
assert.equal(old.isConnected, false);
app.exported('Synthetic answer B').querySelector('#assistant-export-print').click();
await tick();
const shares = app.calls.shares.length;
app.calls.toasts.length = 0;
pending.resolve({ uri: 'file://' + app.calls.writes[0].path });
await tick();
assert.equal(app.calls.shares.length, shares);
assert.deepEqual(app.calls.prints, []);
assert.deepEqual(app.calls.toasts, []);
assert.notEqual(app.calls.writes[0].path, app.calls.writes[1].path);
});
for (const stage of ['native rejection', 'share rejection', 'native return', 'browser fallback']) {
test(`inline ${stage} checks original owner before fallbacks`, async t => {
const app = ui(t, { native: true });
app.w.Capacitor.Plugins = app.plugins();
if (stage.startsWith('native')) app.w.NativePrint = { printHtml() { app.freezeAndReplace(); if (stage.endsWith('rejection')) throw new Error('unavailable'); } };
if (stage === 'share rejection') app.w.Capacitor.Plugins.Share.share = async () => { app.freezeAndReplace(); throw new Error('unavailable'); };
if (stage === 'browser fallback') {
app.w.Capacitor.Plugins = {};
// The click handler yields on its first helper even if there is no bridge.
}
const modal = app.exported();
modal.querySelector('#assistant-export-print').click();
if (stage === 'browser fallback') app.freezeAndReplace();
await tick();
assert.deepEqual(app.calls.prints, []);
assert.deepEqual(app.calls.toasts, []);
assert.equal(app.calls.writes.length, stage === 'share rejection' ? 1 : 0);
assert.equal(modal.isConnected, false);
});
}
for (const mode of ['native', 'filesystem', 'browser']) {
test(`same-owner inline export succeeds: ${mode}`, async t => {
const app = ui(t, { native: true });
if (mode === 'native') app.w.NativePrint = { printHtml: (...args) => app.calls.prints.push(args) };
if (mode === 'filesystem') app.w.Capacitor.Plugins = app.plugins();
app.exported().querySelector('#assistant-export-print').click();
await tick();
assert.equal(app.calls.prints.length + app.calls.shares.length, 1);
const encoded = mode === 'native' ? app.calls.prints[0][1] : app.calls.writes[0]?.data;
if (encoded) {
const html = Buffer.from(encoded, 'base64').toString();
assert.match(html, /Synthetic answer A/);
assert.ok(html.includes(image));
}
});
}
for (const frozen of [false, true]) {
test(`desktop export ${frozen ? 'closes on freeze without delayed/click print' : 'prints on timer and click'}`, t => {
const app = ui(t, { inline: false });
const popup = new JSDOM('<body></body>', { url: 'https://example.test' });
const p = popup.window;
let closed = 0;
p.focus = () => {};
p.print = () => app.calls.prints.push('popup');
const dispose = p.close.bind(p);
t.after(dispose);
p.close = () => { closed++; }; // retain detached callbacks to exercise them
app.w.open = () => p;
app.exported();
const print = p.document.querySelector('#assistant-export-print');
if (frozen) app.freezeAndReplace();
for (const timer of app.timers) timer();
print.click();
assert.equal(app.calls.prints.length, frozen ? 0 : 2);
assert.equal(closed > 0, frozen);
assert.ok(app.w.document.querySelector('#unrelated'));
});
}
test('image preview closes only its owned modal on boundary closure', t => {
const app = ui(t);
const unrelated = app.w.document.createElement('div');
unrelated.className = 'assistant-image-modal';
app.w.document.body.append(unrelated);
app.store.renderGeneratedImage(image);
app.store.openImagePreview('img-1');
assert.equal(unrelated.isConnected, true);
const owned = app.w.document.querySelector('.assistant-image-modal[role="dialog"]');
assert.ok(owned);
app.freezeAndReplace();
assert.equal(owned.isConnected, false);
assert.equal(unrelated.isConnected, true);
});