pediatric-ai-scribe-v3/test/account-boundary.test.js
Daniel 025290d64a
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m1s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: retire Learning Hub
My Resources generates better slides than Learning Hub ever did — a typed deck
the model fills in, rendered by python-pptx with fit-to-slide text, figures, a
vision review and themes, against Learning Hub's markdown-through-pandoc — and
the articles and quizzes now live in the quiz app. Keeping a second, weaker
generator and a whole CMS beside it was not earning its maintenance.

Removed: three routers, the Learning Hub and Content Manager tabs, their
components and frontend modules, the five database tables, the WebDAV browser,
the content embedding column and its vector index.

Content was exported first — every article as markdown plus a full SQL dump of
all five tables — to ops-backups/learning-hub-export-*. That export is the
restore path; the migration's down() can recreate the shape but never the rows,
and says so.

Two things this simplifies rather than merely deletes:

generated_image_links existed only to record which published content an image
appeared in, and it was the sole reason a generated image could be read by
someone who did not make it. Images are now owner-only — the visibility rule is
one WHERE clause instead of a join across two tables and a published flag.

embeddings.js keeps the model discovery the admin panel uses and loses
searchSimilar and generateContentEmbedding, which queried a table that no longer
exists.

Kept deliberately: Nextcloud connect, disconnect and export, which are how a
generated note reaches a real filesystem and have nothing to do with Learning
Hub; learningRetrieval, which despite its name is the clinical corpus search My
Resources depends on; and the pandoc reference deck, still the fallback when the
python renderer fails, moved from assets/learning to assets/deck now that the
old name misleads.

Tests: four Learning-Hub-only files removed, and the individual cases inside
shared files that asserted its behaviour. Where a test used a Learning endpoint
only as a convenient example — the account-boundary token test, the policy
matrix — it now uses one that still exists, so the property it proves is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 20:14:20 +02:00

1120 lines
61 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 { webcrypto } = require('node:crypto');
const { JSDOM } = require('jsdom');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const tick = () => new Promise(resolve => setImmediate(resolve));
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
const response = (data, status = 200) => ({ status, ok: status < 400, json: async () => data });
// Real application scripts, DOM and ES-module bodies. Only transport, storage,
// time, recorder hardware and navigation are faked; no external resources load.
async function browser({ local = {}, session = {}, native = false, broadcast = false, url = 'https://offline.invalid/' } = {}) {
const dom = new JSDOM(read('public/index.html'), { url });
const w = dom.window;
await tick(); // Avoid a duplicate DOMContentLoaded when evaluating auth.js below.
for (const [k, v] of Object.entries(local)) w.localStorage.setItem(k, v);
for (const [k, v] of Object.entries(session)) w.sessionStorage.setItem(k, v);
const timers = new Map(), calls = [], secure = new Map(), channels = [];
let serial = 0, reloads = 0;
const c = {
document: w.document, localStorage: w.localStorage, sessionStorage: w.sessionStorage,
Event: w.Event, CustomEvent: w.CustomEvent, DOMException: w.DOMException,
AbortController: w.AbortController, URL: w.URL, URLSearchParams: w.URLSearchParams,
Blob: w.Blob, FormData: w.FormData, crypto: webcrypto, history: w.history,
location: { href: url, origin: new URL(url).origin, pathname: '/', search: new URL(url).search,
reload() { reloads++; if (c.reloadThrows) throw new Error('navigation denied'); } },
navigator: { mediaDevices: { getUserMedia: () => Promise.reject(new Error('No fake mic configured')) },
clipboard: { writeText: text => { c.clipboard = text; return Promise.resolve(); } },
sendBeacon: (...args) => { calls.push({ beacon: args }); return true; } },
addEventListener: w.addEventListener.bind(w), removeEventListener: w.removeEventListener.bind(w),
dispatchEvent: w.dispatchEvent.bind(w),
console: { log() {}, warn() {}, error() {} },
setTimeout(fn, ms) { const id = ++serial; timers.set(id, { fn, ms }); return id; },
clearTimeout(id) { timers.delete(id); }, setInterval() { return ++serial; }, clearInterval() {},
showToast() {}, showLoading() {}, hideLoading() {}, showBusy() {}, hideBusy() {},
getAuthHeaders() { return {}; },
fetch(input, init = {}) {
const url = typeof input === 'string' ? input : input.url || input.href;
calls.push({ url, init });
return c.api(url, init);
},
api: async () => response({ success: true }),
Capacitor: { isNativePlatform: () => native, Plugins: { SecureStoragePlugin: {
get: async ({ key }) => ({ value: secure.get(key) }),
set: async ({ key, value }) => { secure.set(key, value); },
remove: async ({ key }) => { secure.delete(key); }
} } },
MediaRecorder: class extends w.EventTarget {
constructor(stream) { super(); this.stream = stream; this.state = 'inactive'; }
start() { this.state = 'recording'; }
stop() { this.state = 'inactive'; this.stopped = true; }
static isTypeSupported() { return true; }
},
SpeechRecognition: class { start() { this.started = true; } abort() { this.aborted = true; } }
};
if (broadcast) c.BroadcastChannel = class { constructor() { channels.push(this); } postMessage(data) { this.sent = data; } };
c.window = c;
vm.createContext(c);
const cache = {};
function module(file) {
if (cache[file]) return cache[file];
const imports = {};
let source = read(file).replace(/^import ([\s\S]+?) from '([^']+)';$/gm, (_, names, relative) => {
const dependency = module(path.posix.normalize(path.posix.join(path.posix.dirname(file), relative)));
if (names.startsWith('* as ')) imports[names.slice(5)] = dependency;
else names.slice(1, -1).split(',').forEach(part => {
const [from, to = from] = part.trim().split(/\s+as\s+/); imports[to] = dependency[from];
});
return '';
});
const exports = [...source.matchAll(/export (?:async )?function (\w+)/g)].map(match => match[1]);
source = source.replace(/\bexport /g, '');
const fn = vm.runInContext('(function(' + Object.keys(imports).join(',') + ') {\n' + source + '\nreturn {' + exports.join(',') + '};\n})', c, { filename: file });
return cache[file] = fn(...Object.values(imports));
}
function script(file) { vm.runInContext(read(file), c, { filename: file }); }
script('public/js/secureStorage.js');
script('public/js/accountBoundary.js'); module('public/js/authFetch.js');
function owner(id) { assert.equal(c.AccountBoundary.enter({ id }, true), true); c.CURRENT_USER = { id }; }
let ssoHandler;
async function auth(id, meStatus = 200) {
c.api = async url => response(url === '/api/auth/me' ? (id ? { user: { id, name: id, role: 'user' } } : {}) : {}, url === '/api/auth/me' ? meStatus : 200);
// jsdom cannot synthesize trusted clicks: retain the actual registered handler.
const button = w.document.getElementById('btn-sso');
const add = button.addEventListener.bind(button);
button.addEventListener = (type, handler, options) => {
if (type === 'click') ssoHandler = handler;
return add(type, handler, options);
};
script('public/js/auth.js');
w.document.dispatchEvent(new w.Event('DOMContentLoaded'));
await tick();
}
function login(id) {
c.api = async url => response(url === '/api/auth/login' ? { success: true, token: 'token-' + id, sessionId: 'sid-' + id, user: { id, name: id, role: 'user' } } : {});
w.document.getElementById('login-email').value = id + '@example.invalid';
w.document.getElementById('login-password').value = 'fake-password';
w.document.getElementById('login-form').dispatchEvent(new w.Event('submit', { cancelable: true }));
}
function storageMessage(data) {
w.localStorage.setItem(c.AccountBoundary.key, JSON.stringify(data));
w.dispatchEvent(new w.StorageEvent('storage', { key: c.AccountBoundary.key, newValue: JSON.stringify(data) }));
}
function runTimers(ms) { for (const [id, timer] of [...timers]) if (timer.ms === ms) { timers.delete(id); timer.fn(); } }
function sso(trusted = true) {
const event = { isTrusted: trusted, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
return { event, done: Promise.resolve(ssoHandler(event)) };
}
return { c, w, calls, secure, channels, owner, auth, login, sso, module, script, storageMessage, runTimers,
reloads: () => reloads, snapshot: () => ({ local: { ...w.localStorage }, session: { ...w.sessionStorage } }), close: () => w.close() };
}
function fakeIndexedDB(c, records) {
let next = 20;
const transactions = [];
const db = {
transaction() {
const tx = new c.window.document.defaultView.EventTarget();
transactions.push(tx);
let pending = 0, aborted = false;
const writes = [];
const later = fn => { pending++; queueMicrotask(() => { if (!aborted) fn(); pending--; complete(); }); };
function complete() {
if (pending || aborted) return;
for (const write of writes.splice(0)) write();
tx.dispatchEvent(new c.Event('complete')); if (tx.oncomplete) tx.oncomplete();
}
tx.abort = () => { aborted = true; tx.dispatchEvent(new c.Event('abort')); if (tx.onabort) tx.onabort(); };
function request(value) { const req = {}; later(() => { req.result = value; if (req.onsuccess) req.onsuccess(); }); return req; }
tx.objectStore = () => ({
get: id => request(records.get(id)), getAll: () => request([...records.values()]),
add(record) { const id = ++next; writes.push(() => records.set(id, { ...record, id })); return request(id); },
delete(id) { writes.push(() => records.delete(id)); later(() => {}); },
openCursor() {
const req = {}, values = [...records.values()]; let index = 0;
function advance() {
later(() => {
const value = values[index++];
req.result = value ? { value, delete() { writes.push(() => records.delete(value.id)); }, continue: advance } : null;
if (req.onsuccess) req.onsuccess({ target: req });
});
}
advance(); return req;
}
});
return tx;
}
};
c.indexedDB = { open() { const req = {}; queueMicrotask(() => req.onsuccess({ target: { result: db } })); return req; } };
return transactions;
}
const preferences = { ped_last_tab: 'clinical-assistant', ped_sidebar_collapsed: '1', ped_web_speech_enabled: '1', 'other-app': 'retain' };
test('actual auth: A → failed logout → fresh login B; canceled/throwing reload stays hidden, inert and recoverable', async () => {
const a = await browser({ local: { ...preferences, ped_ed_draft_v1: 'legacy PHI', ped_visit_statuses: 'legacy' }, session: { _savedEncId_ed: 'old', _idempKey_ed: 'old', ped_visit_age: 'old' } });
try {
await a.auth('A');
assert.equal(a.c.AccountBoundary.capture(), 'A');
assert.equal(a.w.localStorage.getItem('ped_ed_draft_v1'), 'legacy PHI');
assert.equal(a.w.sessionStorage.getItem('_savedEncId_ed'), 'old');
a.c.reloadThrows = true;
a.c.api = async () => { throw new Error('offline logout'); };
a.w.document.getElementById('btn-logout').click();
assert.equal(a.c.AccountBoundary.blocked(), true);
assert.equal(a.w.document.getElementById('main-app').inert, true);
assert.equal(a.w.document.documentElement.classList.contains('account-transition'), true);
assert.equal(a.w.document.getElementById('account-recovery').inert, undefined);
a.w.dispatchEvent(new a.c.Event('beforeunload'));
await tick();
assert.ok(a.reloads());
assert.equal(a.c.AccountBoundary.enter({ id: 'B' }, true), false);
await assert.rejects(a.c.fetch('/api/notes', { method: 'POST', keepalive: true }), { name: 'AbortError' });
const b = await browser(a.snapshot());
try {
await b.auth('A'); // Failed logout's cookie remains valid, but must not be consulted.
assert.ok(!b.calls.some(call => call.url === '/api/auth/me'));
assert.equal(b.c.CURRENT_USER, undefined);
b.login('B'); await tick();
assert.equal(b.c.AccountBoundary.capture(), 'B');
for (const [key, value] of Object.entries(preferences)) assert.equal(b.w.localStorage.getItem(key), value);
} finally { b.close(); }
} finally { a.close(); }
});
test('401, storage fallback, BroadcastChannel and BFCache all converge; queued A event cannot erase B native credentials', async () => {
for (const mode of ['401', 'storage', 'broadcast', 'bfcache']) {
const b = await browser({ native: true, broadcast: mode === 'broadcast' });
try {
b.owner('A'); b.secure.set('ped_scribe_token', 'token-B');
const late = deferred(); b.c.api = () => late.promise;
const pending = b.c.fetch('/api/notes').catch(err => err);
if (mode === '401') late.resolve(response({}, 401));
else if (mode === 'bfcache') b.w.dispatchEvent(new b.w.PageTransitionEvent('pageshow', { persisted: true }));
else {
const state = { owner: 'B', generation: 'new-B', signedOut: false };
if (mode === 'storage') b.storageMessage(state);
else { b.w.localStorage.setItem(b.c.AccountBoundary.key, JSON.stringify(state)); b.channels[0].onmessage({ data: state }); }
late.resolve(response({ secret: 'A' }));
}
if (mode === 'bfcache') late.resolve(response({ secret: 'A' }));
assert.equal((await pending).name, 'AbortError');
assert.equal(b.c.AccountBoundary.blocked(), true);
assert.ok(b.reloads());
assert.equal(b.secure.get('ped_scribe_token'), 'token-B');
assert.equal(b.calls[0].init.signal.aborted, true);
} finally { b.close(); }
}
const b = await browser({ broadcast: true });
try {
b.owner('B'); const count = b.reloads();
b.channels[0].onmessage({ data: { owner: 'A', generation: 'obsolete', signedOut: true } });
assert.equal(b.reloads(), count); assert.equal(b.c.AccountBoundary.capture(), 'B');
} finally { b.close(); }
});
test('fetch uses actual same-origin URL, gates pre-auth PHI, pending body/Request/caller abort and beacon writes', async () => {
const b = await browser();
try {
await assert.rejects(b.c.fetch('/api/notes'), { name: 'AbortError' });
assert.equal(b.calls.length, 0);
b.owner('A'); b.c.api = async () => response({}, 401);
await b.c.fetch('https://external.invalid/api/notes');
await b.c.fetch('/not-api?next=/api/notes');
assert.equal(b.c.AccountBoundary.active(), true);
const body = deferred();
b.c.api = async () => ({ status: 200, json: () => body.promise });
const req = new Request('https://offline.invalid/api/notes', { method: 'PUT', body: 'A' });
const resp = await b.c.fetch(req); const pending = resp.json().catch(err => err);
b.c.AccountBoundary.end(); body.resolve({ secret: 'A' });
assert.equal((await pending).name, 'AbortError');
assert.equal(b.c.navigator.sendBeacon('/api/logs/client-error', 'old clinical text'), false);
await assert.rejects(b.c.fetch(new b.c.URL('/api/notes', b.c.location.href)), { name: 'AbortError' });
} finally { b.close(); }
const a = await browser();
try {
a.owner('A'); const abort = new a.c.AbortController(); abort.abort();
await a.c.fetch('/api/notes', { signal: abort.signal });
assert.equal(a.calls[0].init.signal.aborted, true);
} finally { a.close(); }
});
test('actual native auth preserves logout headers and waits for new token/user/session persistence before replacement reload', async () => {
const b = await browser({ native: true });
try {
b.secure.set('ped_scribe_token', 'token-A'); await b.auth('A');
const persisted = deferred(); const originalSet = b.c.SecureStorage.set;
b.c.SecureStorage.set = async (key, value) => { await persisted.promise; return originalSet(key, value); };
b.login('B'); await tick();
assert.equal(b.c.AccountBoundary.blocked(), true);
assert.equal(b.reloads(), 0);
assert.equal(b.secure.get('ped_scribe_token'), 'token-A');
persisted.resolve(); await tick();
assert.equal(b.secure.get('ped_scribe_token'), 'token-B');
assert.equal(b.secure.get('ped_session_id'), 'sid-B');
assert.equal(JSON.parse(b.secure.get('ped_scribe_user')).id, 'B');
assert.ok(b.reloads());
assert.equal(b.c.CURRENT_USER.id, 'A', 'B must not enter retained A module state');
} finally { b.close(); }
const a = await browser({ native: true });
try {
a.secure.set('ped_scribe_token', 'token-A'); await a.auth('A');
a.w.document.getElementById('btn-logout').click(); await tick();
assert.equal(a.calls.find(call => call.url === '/api/auth/logout').init.headers.Authorization, 'Bearer token-A');
assert.equal(a.secure.has('ped_scribe_token'), false);
} finally { a.close(); }
});
test('actual SSO boot respects signed-out latch, one-shot bounded intent and verified /me result', async () => {
const latch = JSON.stringify({ generation: 'logout', owner: null, signedOut: true });
for (const [intent, user, allowed] of [[null, 'A', false], [Date.now() - 600000, 'A', false], [Date.now(), null, false], [Date.now(), 'B', true]]) {
const b = await browser({ url: 'https://offline.invalid/?sso=ok', local: { ped_account_boundary_v1: latch }, session: intent ? { ped_sso_intent: String(intent) } : {} });
try {
await b.auth(user);
assert.equal(b.c.AccountBoundary.active(), allowed);
assert.equal(b.c.AccountBoundary.signedOut(), !allowed);
assert.equal(b.w.sessionStorage.getItem('ped_sso_intent'), null);
} finally { b.close(); }
}
});
test('actual SSO start freezes and publishes before logout, requires cookie-only 401, then keeps the existing href', async () => {
for (const owned of [false, true]) {
const b = await browser({ native: true, local: owned ? {} : {
ped_account_boundary_v1: JSON.stringify({ owner: null, signedOut: true, generation: 'old-logout' })
} });
try {
b.secure.set('ped_scribe_token', 'token-A'); await b.auth('A');
const logout = deferred(), probe = deferred();
b.calls.length = 0;
b.c.api = url => {
assert.equal(b.c.AccountBoundary.blocked(), true, 'Freeze precedes transport/cookie mutation');
assert.equal(b.c.AccountBoundary.signedOut(), true, 'Publish precedes transport/cookie mutation');
return url === '/api/auth/logout' ? logout.promise : probe.promise;
};
const href = b.w.document.getElementById('btn-sso').href;
const start = b.sso();
assert.equal(start.event.defaultPrevented, true);
assert.equal(b.c.AccountBoundary.blocked(), true);
assert.equal(b.c.AccountBoundary.signedOut(), true);
assert.equal(b.w.document.activeElement.textContent, 'Reload');
assert.equal(b.calls.length, 1);
assert.equal(b.calls[0].init.method, 'POST');
assert.equal(b.calls[0].init.credentials, 'same-origin');
assert.equal(b.calls[0].init.headers.Authorization, owned ? 'Bearer token-A' : undefined, 'Signed-out requests never use an unverified native token');
assert.equal(b.w.sessionStorage.getItem('ped_sso_intent'), null);
logout.resolve(response({ success: true })); await tick();
assert.equal(b.calls[1].url, '/api/auth/me');
assert.equal(b.calls[1].init.credentials, 'same-origin');
assert.equal(b.calls[1].init.headers, undefined, 'Old native Authorization must not reach the probe');
assert.equal(b.calls[1].init.cache, 'no-store');
assert.equal(b.w.sessionStorage.getItem('ped_sso_intent'), null);
probe.resolve(response({}, 401)); await start.done;
assert.equal(b.c.location.href, href);
assert.ok(Date.now() - Number(b.w.sessionStorage.getItem('ped_sso_intent')) < 5000);
assert.equal(b.c.AccountBoundary.blocked(), true, 'Canceled navigation must not unlock this realm');
const next = await browser({ ...b.snapshot(), url: 'https://offline.invalid/?sso=ok', native: true });
try {
await next.auth('B');
assert.equal(next.c.AccountBoundary.capture(), 'B');
assert.equal(next.w.sessionStorage.getItem('ped_sso_intent'), null);
} finally { next.close(); }
} finally { b.close(); }
}
});
test('actual SSO failure, false logout success, stale sibling and navigation denial stay latched without erasing B credentials', async () => {
for (const mode of ['logout-network', 'logout-500', 'probe-network', 'probe-200', 'probe-403', 'probe-503', 'sibling', 'navigation']) {
const b = await browser({ native: true, broadcast: true });
try {
b.secure.set('ped_scribe_token', 'token-A'); await b.auth('A');
const probe = deferred();
b.c.api = async url => {
if (url === '/api/auth/logout') {
if (mode === 'logout-network') throw new Error('offline');
return response({ success: true }, mode === 'logout-500' ? 500 : 200);
}
return probe.promise;
};
if (mode === 'navigation') Object.defineProperty(b.c.location, 'href', {
get: () => 'https://offline.invalid/', set() { throw new Error('navigation denied'); }
});
const start = b.sso(); await tick();
if (mode === 'sibling') {
b.secure.set('ped_scribe_token', 'token-B');
b.storageMessage({ owner: 'B', generation: 'new-B', signedOut: false });
b.channels[0].onmessage({ data: { owner: 'A', generation: 'obsolete-A', signedOut: true } });
}
if (mode === 'probe-network') probe.reject(new Error('offline'));
else probe.resolve(response({}, /^probe-/.test(mode) ? Number(mode.slice(6)) : 401));
await start.done;
assert.equal(b.c.location.href, 'https://offline.invalid/');
assert.equal(b.w.sessionStorage.getItem('ped_sso_intent'), null);
assert.equal(b.c.AccountBoundary.blocked(), true);
assert.equal(b.w.document.getElementById('account-recovery').getAttribute('role'), 'alert');
assert.equal(b.w.document.getElementById('main-app').inert, true);
if (mode === 'sibling') {
assert.equal(b.c.AccountBoundary.read().owner, 'B');
assert.equal(b.secure.get('ped_scribe_token'), 'token-B');
} else assert.equal(b.c.AccountBoundary.signedOut(), true);
} finally { b.close(); }
}
});
test('apparently signed-out SSO verifies cookie absence; synthetic clicks never create intent', async () => {
const b = await browser();
try {
await b.auth(null); b.calls.length = 0;
b.c.api = async url => response({}, url === '/api/auth/me' ? 401 : 200);
const synthetic = b.sso(false); await synthetic.done;
assert.equal(synthetic.event.defaultPrevented, true);
assert.equal(b.w.sessionStorage.getItem('ped_sso_intent'), null);
assert.equal(b.calls.length, 0);
const start = b.sso(); await start.done;
assert.equal(start.event.defaultPrevented, true);
assert.deepEqual(b.calls.map(call => call.url), ['/api/auth/logout', '/api/auth/me']);
assert.equal(b.c.AccountBoundary.blocked(), true);
assert.ok(b.w.sessionStorage.getItem('ped_sso_intent'));
assert.equal(b.c.location.href, b.w.document.getElementById('btn-sso').href);
assert.equal(b.w.document.getElementById('btn-sso').getAttribute('href'), '/api/auth/oidc');
} finally { b.close(); }
});
test('SSO after bootstrap failure clears an unobserved old cookie before Back can restore its account', async () => {
const b = await browser();
try {
await b.auth('A', 503);
assert.equal(b.c.AccountBoundary.read(), null);
assert.equal(b.c.AccountBoundary.active(), false);
let oldCookie = true;
b.calls.length = 0;
b.c.api = async url => {
assert.equal(b.c.AccountBoundary.blocked(), true);
assert.equal(b.c.AccountBoundary.signedOut(), true);
if (url === '/api/auth/logout') { oldCookie = false; return response({ success: true }); }
assert.equal(url, '/api/auth/me');
return oldCookie ? response({ user: { id: 'A' } }) : response({}, 401);
};
const start = b.sso();
assert.equal(start.event.defaultPrevented, true, 'Missing owner metadata is not proof of cookie absence');
await start.done;
assert.equal(oldCookie, false);
assert.deepEqual(b.calls.map(call => call.url), ['/api/auth/logout', '/api/auth/me']);
assert.equal(b.calls[1].init.headers, undefined);
assert.equal(b.calls[1].init.cache, 'no-store');
assert.equal(b.c.location.href, b.w.document.getElementById('btn-sso').href);
const back = await browser(b.snapshot());
try {
await back.auth('A'); // Even a surviving old server session cannot silently re-enter.
assert.equal(back.c.AccountBoundary.active(), false);
assert.ok(!back.calls.some(call => call.url === '/api/auth/me'));
} finally { back.close(); }
} finally { b.close(); }
});
test('platform recorder guards stop actual AudioRecorder callers, speech and late mic permission without old audio writes', async () => {
const b = await browser();
try {
b.owner('A');
// Evaluate the real shared recorder implementation, not a rewritten test recorder.
const source = read('public/js/app.js');
vm.runInContext(source.slice(source.indexOf('function AudioRecorder()'), source.indexOf('// ── Native mobile helpers')), b.c);
const stream = { getTracks: () => [track] }, track = { stop() { this.stopped = true; } };
// Install fake hardware before a fresh boundary (the production script wraps it at boot).
const mic = deferred();
const fresh = await browser();
try {
fresh.c.navigator.mediaDevices.getUserMedia = () => mic.promise;
fresh.script('public/js/accountBoundary.js'); fresh.owner('A');
vm.runInContext(source.slice(source.indexOf('function AudioRecorder()'), source.indexOf('// ── Native mobile helpers')), fresh.c);
const recorder = new fresh.c.AudioRecorder(); const start = recorder.start().catch(err => err);
fresh.c.AccountBoundary.end(); mic.resolve(stream);
assert.equal((await start).name, 'AbortError'); assert.equal(track.stopped, true);
} finally { fresh.close(); }
const rec = new b.c.MediaRecorder(stream); rec.onstop = () => { throw new Error('Late callback'); }; rec.start();
const speech = new b.c.SpeechRecognition(); speech.start();
b.c.AccountBoundary.end();
assert.equal(rec.stopped, true); assert.equal(rec.onstop, null); assert.equal(speech.aborted, true);
assert.throws(() => rec.start(), { name: 'AbortError' });
} finally { b.close(); }
});
test('actual audio server-first fallback and IndexedDB transactions cannot migrate across owners; list/retry/delete enforce owner', async () => {
const records = new Map([
[1, { id: 1, owner: 'A', timestamp: Date.now(), blob: 'audio-A' }],
[2, { id: 2, owner: 'B', timestamp: Date.now(), blob: 'audio-B' }],
[3, { id: 3, timestamp: Date.now(), blob: 'legacy' }],
[4, { id: 4, timestamp: Date.now() - 86400001, blob: 'expired legacy' }]
]);
const a = await browser();
try {
fakeIndexedDB(a.c, records); a.module('public/js/audioBackup.js'); await tick();
assert.equal(records.has(3), true, 'Fresh unowned audio survives boot without adoption');
assert.equal(records.has(4), false, 'Existing 24-hour age policy still applies');
a.owner('A');
const delayed = deferred(); a.c.api = () => delayed.promise;
const save = a.c.saveAudioBackup(new a.c.Blob(['old']), 'notes');
a.c.AccountBoundary.end(); delayed.reject(new Error('offline'));
assert.equal(await save, null); assert.equal(records.size, 3);
} finally { a.close(); }
const b = await browser();
try {
b.owner('B'); fakeIndexedDB(b.c, records); b.module('public/js/audioBackup.js');
b.c.api = async () => response({ success: false });
const backups = await b.c.getAudioBackups();
assert.deepEqual(Array.from(backups, row => row.id), ['local_2']);
for (const id of [1, 3]) {
await assert.rejects(b.c.retryAudioBackup('local_' + id), /Backup not found/);
await b.c.deleteAudioBackup('local_' + id); assert.equal(records.has(id), true);
}
let transcribed; b.c.transcribeAudio = async blob => { transcribed = blob; return { success: true, text: 'B text' }; };
await b.c.retryAudioBackup('local_2'); assert.equal(transcribed, 'audio-B');
await b.c.deleteAudioBackup('local_2'); assert.equal(records.has(2), false);
const id = await b.c.saveAudioBackup(new b.c.Blob(['B']), 'notes');
assert.equal(records.get(id).owner, 'B');
} finally { b.close(); }
});
test('actual Notes autosave, beforeunload keepalive and recorder continuations are discarded on transition', async () => {
const b = await browser();
try {
b.owner('A'); b.w.document.getElementById('notes-tab').innerHTML = read('public/components/notes.html');
b.c.api = async () => response({ success: true, notes: [] });
b.module('public/js/notes.js');
b.w.document.dispatchEvent(new b.c.CustomEvent('tabChanged', { detail: { tab: 'notes' } })); await tick();
b.w.document.getElementById('btn-notes-new').click();
const title = b.w.document.getElementById('note-title'); title.value = 'A private note'; title.dispatchEvent(new b.c.Event('input'));
b.w.dispatchEvent(new b.c.Event('beforeunload')); await tick();
assert.ok(b.calls.some(call => call.init.keepalive && JSON.parse(call.init.body).title === 'A private note'));
const delayed = deferred(); b.c.api = () => delayed.promise;
b.runTimers(1200); const count = b.calls.length;
b.c.AccountBoundary.end();
b.w.dispatchEvent(new b.c.Event('beforeunload')); b.runTimers(1200);
delayed.resolve(response({ success: true, id: 42 })); await tick(); b.runTimers(1200);
assert.equal(b.calls.length, count, 'No late autosave/list refresh/keepalive');
} finally { b.close(); }
const a = await browser();
try {
a.owner('A'); const stopped = deferred();
a.c.AudioRecorder = class { start() { return Promise.resolve(); } stop() { return stopped.promise; } };
let transcribed = 0, applied = 0;
a.c.transcribeAudio = async () => { transcribed++; return { success: true, text: 'A' }; };
const { createNotesRecorder } = a.module('public/js/notes/recorder.js');
const recorder = createNotesRecorder({ noteFromVoice: async () => ({ success: true }), applyGeneratedNote() { applied++; } });
recorder.start(); await tick(); recorder.stop(false); a.c.AccountBoundary.end();
stopped.resolve(new a.c.Blob(['audio'])); await tick();
assert.equal(transcribed, 0); assert.equal(applied, 0);
} finally { a.close(); }
});
test('actual ED draft/idempotency/save callbacks stay owner-scoped; delayed persistence never adopts B', async () => {
const a = await browser({ local: preferences });
let snapshot;
try {
a.owner('A'); a.w.document.getElementById('ed-tab').innerHTML = read('public/components/ed-encounter.html');
a.script('public/js/encounters.js'); a.module('public/js/ed-encounters.js');
const label = a.w.document.getElementById('ed-label'); label.value = 'A patient'; label.dispatchEvent(new a.c.Event('input', { bubbles: true }));
a.runTimers(300);
assert.match(a.w.localStorage.getItem('ped_ed_draft_v1:owner:A'), /A patient/);
const delayed = deferred(); a.c.api = () => delayed.promise;
a.w.document.getElementById('btn-ed-save').click();
label.value = 'late A edit'; label.dispatchEvent(new a.c.Event('input', { bubbles: true }));
a.c.AccountBoundary.end(); a.runTimers(300);
delayed.resolve(response({ success: true, id: 91 })); await tick();
assert.equal(a.c._savedEncId_ed, undefined);
assert.equal(a.w.sessionStorage.getItem('_savedEncId_ed:owner:A'), null);
assert.doesNotMatch(a.w.localStorage.getItem('ped_ed_draft_v1:owner:A'), /late A edit/);
snapshot = a.snapshot();
} finally { a.close(); }
const b = await browser(snapshot);
try {
b.owner('B'); b.w.document.getElementById('ed-tab').innerHTML = '<input id="ed-label"><button id="btn-ed-save"></button>';
b.script('public/js/encounters.js'); b.module('public/js/ed-encounters.js');
b.w.document.dispatchEvent(new b.c.CustomEvent('tabChanged', { detail: { tab: 'ed' } }));
assert.equal(b.w.document.getElementById('ed-label').value, '');
b.w.document.getElementById('ed-label').value = 'B patient'; b.w.document.getElementById('btn-ed-save').click();
assert.ok(b.w.sessionStorage.getItem('_idempKey_ed:owner:B'));
assert.notEqual(b.w.sessionStorage.getItem('_idempKey_ed:owner:B'), b.w.sessionStorage.getItem('_idempKey_ed:owner:A'));
} finally { b.close(); }
});
test('legacy ED, visit and idempotency storage is preserved but never adopted by B actual modules', async () => {
const local = {
ped_ed_draft_v1: JSON.stringify({ label: 'Legacy patient', state: { stages: [] } }),
ped_visit_statuses: JSON.stringify({ 'newborn.newbornBlood': { status: 'Done', note: 'Legacy clinical note' } })
};
const session = { _savedEncId_ed: 'legacy-42', _idempKey_ed: 'legacy-key', ped_visit_age: 'Legacy age' };
const b = await browser({ local, session });
try {
b.owner('B');
b.w.document.getElementById('ed-tab').innerHTML = '<input id="ed-label"><button id="btn-ed-save"></button>';
b.w.document.getElementById('wellvisit-tab').innerHTML = read('public/components/wellvisit.html');
b.script('public/js/encounters.js'); b.module('public/js/ed-encounters.js');
const schedule = b.module('public/js/wellVisit/scheduleData.js');
schedule.applyWellVisitScheduleGlobals(JSON.parse(read('public/data/well-visit/schedule.json')), b.c);
b.module('public/js/wellVisit.js');
for (const tab of ['ed', 'wellvisit']) b.w.document.dispatchEvent(new b.c.CustomEvent('tabChanged', { detail: { tab } }));
await tick();
assert.equal(b.w.document.getElementById('ed-label').value, '');
assert.equal(b.c._savedEncId_ed, undefined);
assert.ok(![...b.w.document.querySelectorAll('.visit-note-input')].some(input => input.value === 'Legacy clinical note'));
assert.notEqual(b.c._wellVisitAge, 'Legacy age');
b.w.document.getElementById('ed-label').value = 'B patient';
b.w.document.getElementById('btn-ed-save').click(); await tick();
const saved = JSON.parse(b.calls.find(call => call.url === '/api/encounters/saved' && call.init.method === 'POST').init.body);
assert.notEqual(saved.idempotency_key, 'legacy-key');
assert.notEqual(b.w.sessionStorage.getItem('_idempKey_ed:owner:B'), 'legacy-key');
assert.ok(b.w.sessionStorage.getItem('_idempKey_ed:owner:B'));
const note = b.w.document.querySelector('.visit-note-input');
assert.ok(note, 'Real well-visit schedule rendered');
note.value = 'B visit note'; note.dispatchEvent(new b.c.Event('input', { bubbles: true }));
assert.match(b.w.localStorage.getItem('ped_visit_statuses:owner:B'), /B visit note/);
assert.doesNotMatch(b.w.localStorage.getItem('ped_visit_statuses:owner:B'), /Legacy/);
for (const [key, value] of Object.entries(local)) assert.equal(b.w.localStorage.getItem(key), value);
for (const [key, value] of Object.entries(session)) assert.equal(b.w.sessionStorage.getItem(key), value);
} finally { b.close(); }
});
test('actual cookie-authenticated B callers never send a valid leftover A token', async () => {
const b = await browser({ local: { ped_scribe_token: 'valid-token-A' } });
try {
await b.auth('B');
b.c.SecureStorage.getSync = () => 'valid-token-A'; // Hostile leftover cache is not an identity.
b.script('public/js/app.js');
b.c.showToast = b.c.showLoading = b.c.hideLoading = b.c.showBusy = b.c.hideBusy = () => {};
b.c.copyText = () => {};
fakeIndexedDB(b.c, new Map()); b.module('public/js/audioBackup.js');
b.module('public/js/documents.js'); b.module('public/js/myResources.js');
b.calls.length = 0;
// Synthetic server uses the production Bearer-first precedence. A remains valid.
b.c.api = async (url, init) => {
const authenticated = init.headers?.Authorization === 'Bearer valid-token-A' ? 'A' : 'B';
assert.equal(authenticated, 'B', url);
assert.equal(init.headers?.Authorization, undefined, url);
if (init.body instanceof b.c.FormData) assert.equal(init.headers?.['Content-Type'], undefined, url);
if (url.endsWith('/audio')) return { ...response({}), blob: async () => new b.c.Blob(['B audio']) };
if (url === '/api/transcribe/status') return response({ available: true });
return response({ success: true, id: 9, backups: [{ id: 9 }], text: 'B transcript', s3_configured: true, documents: [] });
};
await b.c.getAudioBackups();
await b.c.saveAudioBackup(new b.c.Blob(['B']), 'notes');
await b.c.retryAudioBackup('server_9');
await b.c.deleteAudioBackup('server_9');
b.c.checkTranscribeStatus(); await tick();
assert.equal(b.c._transcribeAvailable, true);
await b.c.transcribeAudio(new b.c.Blob(['B']));
const controls = b.w.document.createElement('div');
controls.innerHTML = '<button data-action="copy" data-target="fixture-copy">Copy</button><input id="doc-file-input" type="file"><button id="btn-doc-upload">Upload</button><div id="documents-list"></div>';
b.w.document.body.append(controls);
Object.defineProperty(controls.querySelector('#doc-file-input'), 'files', { value: [new b.w.File(['B'], 'fixture.txt')] });
controls.querySelector('[data-action]').click();
controls.querySelector('#btn-doc-upload').click();
await tick();
const reached = b.calls.map(call => call.url);
for (const url of ['/api/audio-backups', '/api/audio-backups/9/audio', '/api/audio-backups/9', '/api/transcribe/status', '/api/transcribe', '/api/logs/client-event', '/api/documents/upload']) assert.ok(reached.includes(url), url);
// Also assert recorded headers: callers may intentionally catch transport rejection.
for (const { url, init } of b.calls) {
assert.equal(init.headers?.Authorization, undefined, url);
if (init.body instanceof b.c.FormData) assert.equal(init.headers?.['Content-Type'], undefined, url);
}
assert.equal(b.w.localStorage.getItem('ped_scribe_token'), 'valid-token-A');
} finally { b.close(); }
});
test('actual SecureStorage rejects failed native/local writes, caches only confirmed persistence, and never downgrades native writes', async () => {
const b = await browser({ native: true });
try {
const plugin = b.c.Capacitor.Plugins.SecureStoragePlugin;
b.secure.set('ped_scribe_token', 'token-A');
await b.c.SecureStorage.hydrate(['ped_scribe_token']);
plugin.set = async () => { throw new Error('native write denied'); };
await assert.rejects(b.c.SecureStorage.set('ped_scribe_token', 'token-B'), /native write denied/);
assert.equal(b.c.SecureStorage.getSync('ped_scribe_token'), 'token-A');
assert.equal(b.secure.get('ped_scribe_token'), 'token-A');
delete b.c.Capacitor.Plugins.SecureStoragePlugin;
for (const key of ['ped_scribe_token', 'ped_scribe_user', 'ped_session_id', 'ped_bio_creds']) {
await assert.rejects(b.c.SecureStorage.set(key, 'secret'), /Secure storage unavailable/);
assert.equal(b.w.localStorage.getItem(key), null);
}
} finally { b.close(); }
const web = await browser();
try {
Object.getPrototypeOf(web.w.localStorage).setItem = () => { throw new Error('local write denied'); };
await assert.rejects(web.c.SecureStorage.set('ped_scribe_token', 'token-B'), /local write denied/);
assert.equal(web.c.memCache.ped_scribe_token, undefined);
} finally { web.close(); }
});
test('actual native auth cannot publish B or automatically reload after credential persistence rejection; sibling B is preserved', async () => {
for (const sibling of [false, true]) {
const b = await browser({ native: true });
try {
b.secure.set('ped_scribe_token', 'token-A'); await b.auth('A');
const pending = deferred();
b.c.Capacitor.Plugins.SecureStoragePlugin.set = ({ key }) => key === 'ped_scribe_token' ? pending.promise : Promise.resolve();
b.login('B'); await tick();
if (sibling) {
b.secure.set('ped_scribe_token', 'sibling-token-B');
// Persist the sibling state without delivering an event until the rejection is handled.
b.w.localStorage.setItem(b.c.AccountBoundary.key, JSON.stringify({ owner: 'B', generation: 'sibling-B', signedOut: false }));
}
pending.reject(new Error('native write denied')); await tick();
assert.equal(b.reloads(), 0);
assert.equal(b.c.AccountBoundary.blocked(), true);
assert.notEqual(b.c.CURRENT_USER?.id, 'B');
if (sibling) {
assert.equal(b.c.AccountBoundary.read().generation, 'sibling-B');
assert.equal(b.secure.get('ped_scribe_token'), 'sibling-token-B');
} else {
assert.equal(b.secure.get('ped_scribe_token'), 'token-A');
assert.equal(b.c.SecureStorage.getSync('ped_scribe_token'), 'token-A');
assert.equal(b.c.AccountBoundary.read().signedOut, true);
b.w.document.querySelector('#account-recovery button').click();
assert.equal(b.reloads(), 1);
const fresh = await browser({ ...b.snapshot(), native: true });
try {
fresh.secure.set('ped_scribe_token', 'token-A'); await fresh.auth('A');
assert.ok(!fresh.calls.some(call => call.url === '/api/auth/me'));
assert.equal(fresh.c.AccountBoundary.active(), false);
} finally { fresh.close(); }
}
} finally { b.close(); }
}
});
test('failed durable latch blocks every automatic/recovery reload until safe retry, including native cookie-401 with surviving A', async () => {
for (const native of [false, true]) for (const mode of ['logout', 'sso', 'login', '401', 'unconfirmed-write']) {
const b = await browser({ native });
try {
b.secure.set('ped_scribe_token', 'token-A'); await b.auth('A');
const proto = Object.getPrototypeOf(b.w.localStorage), setItem = proto.setItem;
let attempts = 0;
proto.setItem = function(key, value) {
if (key === b.c.AccountBoundary.key) {
attempts++;
if (mode === 'unconfirmed-write') return;
throw new Error('durable latch denied');
}
return setItem.call(this, key, value);
};
b.c.Capacitor.Plugins.SecureStoragePlugin.remove = async () => { throw new Error('native removal denied'); };
b.c.api = async url => {
if (url === '/api/auth/logout') throw new Error('logout unavailable');
return response({}, 401); // Cookie failure does not revoke a still-valid native token.
};
if (mode === 'sso') await b.sso().done;
else if (mode === 'login') { b.login('B'); await tick(); }
else if (mode === '401') await assert.rejects(b.c.fetch('/api/notes'), { name: 'AbortError' });
else b.w.document.getElementById('btn-logout').click();
await tick(); b.runTimers(3000);
assert.equal(b.c.AccountBoundary.read().owner, 'A', mode);
assert.equal(b.c.AccountBoundary.blocked(), true, mode);
assert.equal(b.reloads(), 0, mode);
const recovery = b.w.document.querySelector('#account-recovery button');
recovery.click();
b.w.dispatchEvent(new b.w.PageTransitionEvent('pageshow', { persisted: true }));
await b.c.AccountBoundary.cookieSessionRequest();
b.c.AccountBoundary.reload();
assert.equal(b.reloads(), 0, mode);
assert.ok(attempts >= 3);
assert.match(b.w.document.querySelector('#account-recovery p').textContent, /Cannot safely reload/);
assert.equal(b.secure.get('ped_scribe_token'), 'token-A');
assert.equal(b.c.AccountBoundary.read().owner, 'A');
proto.setItem = setItem;
recovery.click();
assert.equal(b.reloads(), 1, mode);
assert.equal(b.c.AccountBoundary.read().signedOut, true);
const fresh = await browser({ ...b.snapshot(), native });
try {
fresh.secure.set('ped_scribe_token', 'token-A'); await fresh.auth('A');
assert.ok(!fresh.calls.some(call => call.url === '/api/auth/me'), mode);
assert.equal(fresh.c.AccountBoundary.active(), false);
} finally { fresh.close(); }
} finally { b.close(); }
}
});
test('native SSO B never authenticates with late hydrated A credentials', async () => {
const b = await browser({ native: true, url: 'https://offline.invalid/?sso=ok' });
try {
const stale = deferred();
b.c.Capacitor.Plugins.SecureStoragePlugin.get = ({ key }) => key === 'ped_scribe_token' ? stale.promise : Promise.resolve({ value: null });
await b.auth('B');
assert.equal(b.c.AccountBoundary.capture(), 'B');
assert.equal(b.c.AUTH_TOKEN, '');
stale.resolve({ value: 'valid-token-A' }); await tick();
assert.equal(b.c.SecureStorage.getSync('ped_scribe_token'), 'valid-token-A');
assert.equal(b.c.getAuthHeaders().Authorization, undefined);
} finally { b.close(); }
});
function authForm(b, kind, id = 'A') {
if (kind === 'biometric') { b.w.document.getElementById('btn-bio-login').click(); return; }
const prefix = kind === 'register' ? 'reg' : 'login';
if (kind === 'register') b.w.document.getElementById('reg-name').value = id;
b.w.document.getElementById(prefix + '-email').value = id + '@example.invalid';
b.w.document.getElementById(prefix + '-password').value = 'synthetic-password';
b.w.document.getElementById(kind + '-form').dispatchEvent(new b.c.Event('submit', { cancelable: true }));
}
function enableRegistration(b) {
b.c.turnstile = { render(el, opts) { opts.callback('synthetic-challenge'); return 1; }, reset() {} };
b.c.onloadTurnstileCallback();
b.w.document.getElementById('show-register').click();
}
const loginResponse = id => response({ success: true, token: 'token-' + id, sessionId: 'sid-' + id, user: { id, name: id, role: 'user' } });
test('auth admission holds login/register/biometric busy through retrieval, response and all native writes; overlap and SSO are rejected', async () => {
for (const kind of ['login', 'register', 'biometric']) {
const b = await browser({ native: true });
try {
await b.auth(null); enableRegistration(b);
const biometric = deferred(), network = deferred(), persistence = deferred();
let prompts = 0, hides = 0;
b.c.hideLoading = () => { hides++; };
b.c.Capacitor.Plugins.BiometricAuthNative = {
authenticate() { prompts++; return biometric.promise; }, checkBiometry: async () => ({ isAvailable: false })
};
b.secure.set('ped_bio_creds', JSON.stringify({ username: 'A@example.invalid', password: 'synthetic-password' }));
const plugin = b.c.Capacitor.Plugins.SecureStoragePlugin;
plugin.set = async ({ key, value }) => { await persistence.promise; b.secure.set(key, value); };
b.c.api = () => network.promise;
b.calls.length = 0;
const overlap = async () => {
authForm(b, 'login', 'B'); authForm(b, 'register', 'B'); authForm(b, 'biometric');
const sso = b.sso();
assert.equal(sso.event.defaultPrevented, true);
assert.equal(b.w.sessionStorage.getItem('ped_sso_intent'), null);
assert.equal(b.c.AccountBoundary.blocked(), false, 'Pending authentication must reject SSO before logout/freeze');
await sso.done;
assert.equal(b.w.document.getElementById('auth-screen').getAttribute('aria-busy'), 'true');
assert.equal(hides, 0, 'Loading must remain visible until persistence settles');
};
authForm(b, kind);
if (kind === 'biometric') {
await overlap(); assert.equal(b.calls.length, 0); assert.equal(prompts, 1);
biometric.resolve(); await tick();
}
await overlap(); assert.equal(b.calls.length, 1);
network.resolve(loginResponse('A')); await tick();
await overlap(); assert.equal(b.calls.length, 1);
assert.equal(b.c.CURRENT_USER, undefined);
persistence.resolve(); await tick();
assert.equal(hides, 1);
assert.equal(b.w.document.getElementById('auth-screen').hasAttribute('aria-busy'), false);
assert.equal(b.c.AccountBoundary.capture(), 'A');
assert.equal(b.secure.get('ped_scribe_token'), 'token-A');
assert.equal(b.secure.get('ped_session_id'), 'sid-A');
} finally { b.close(); }
}
});
test('native auth admission waits for successful pending writes even when another credential write rejects', async () => {
const b = await browser({ native: true });
try {
await b.auth(null);
const pending = deferred(); let hides = 0;
b.c.hideLoading = () => { hides++; };
b.c.Capacitor.Plugins.SecureStoragePlugin.set = async ({ key, value }) => {
if (key === 'ped_scribe_user') throw new Error('synthetic write rejection');
await pending.promise; b.secure.set(key, value);
};
b.login('A'); await tick();
assert.equal(hides, 0);
authForm(b, 'login', 'B'); await tick();
assert.equal(b.calls.filter(call => call.url === '/api/auth/login').length, 1);
pending.resolve(); await tick();
assert.equal(hides, 1);
assert.equal(b.c.AccountBoundary.signedOut(), true);
assert.equal(b.c.AccountBoundary.blocked(), true);
assert.equal(b.reloads(), 0);
} finally { b.close(); }
});
test('successful uncancellable native A write after sibling B never autoreloads or adopts A in fresh bootstrap/recovery', async () => {
const a = await browser({ native: true });
try {
await a.auth(null);
const late = deferred();
a.c.Capacitor.Plugins.SecureStoragePlugin.set = async ({ key, value }) => {
if (key === 'ped_scribe_token') await late.promise;
a.secure.set(key, value);
};
a.login('A'); await tick();
// A second actual auth realm completes B against the same synthetic native store.
const sibling = await browser({ ...a.snapshot(), native: true });
let sharedB;
try {
sibling.c.Capacitor.Plugins.SecureStoragePlugin = {
get: async ({ key }) => ({ value: a.secure.get(key) }),
set: async ({ key, value }) => { a.secure.set(key, value); },
remove: async ({ key }) => { a.secure.delete(key); }
};
await sibling.auth(null); sibling.login('B'); await tick();
assert.equal(sibling.c.AccountBoundary.capture(), 'B');
assert.equal(a.secure.get('ped_scribe_token'), 'token-B');
sharedB = sibling.c.AccountBoundary.read();
a.storageMessage(sharedB);
} finally { sibling.close(); }
late.resolve(); await tick();
assert.equal(a.secure.get('ped_scribe_token'), 'token-A', 'Native bridge completion is genuinely successful, not canceled by the test');
assert.equal(JSON.parse(a.secure.get('ped_scribe_user')).id, 'B', 'No stale cleanup may erase newer B');
assert.equal(a.secure.get('ped_session_id'), 'sid-B');
assert.equal(a.c.AccountBoundary.read().generation, sharedB.generation);
assert.equal(a.c.AccountBoundary.blocked(), true);
assert.equal(a.reloads(), 0, 'Late successful writes must not navigate into stale A');
assert.equal(a.c.getAuthHeaders().Authorization, undefined);
const fresh = await browser({ ...a.snapshot(), native: true });
try {
for (const [key, value] of a.secure) fresh.secure.set(key, value);
let writes = 0;
fresh.c.Capacitor.Plugins.SecureStoragePlugin.set = async () => { writes++; };
await fresh.auth('A'); // /me legitimately verifies persisted A, but shared owner is B.
assert.equal(fresh.c.AccountBoundary.active(), false);
assert.equal(fresh.c.CURRENT_USER, undefined);
assert.equal(fresh.c.AccountBoundary.read().generation, sharedB.generation);
assert.equal(writes, 0, 'Passive bootstrap must not persist unadmitted credentials');
assert.equal(fresh.reloads(), 0);
const notice = fresh.w.document.getElementById('account-recovery');
assert.equal(notice.getAttribute('role'), 'alert');
const button = notice.querySelector('button');
assert.equal(button.textContent, 'Sign in again');
assert.equal(fresh.w.document.activeElement, button);
button.click();
assert.equal(fresh.reloads(), 1);
assert.equal(fresh.c.AccountBoundary.read().generation, sharedB.generation, 'Recovery does not sign B out');
const recovery = await browser({ ...fresh.snapshot(), native: true });
try {
for (const [key, value] of a.secure) recovery.secure.set(key, value);
await recovery.auth('A');
assert.equal(recovery.c.AccountBoundary.active(), false);
assert.ok(!recovery.calls.some(call => call.url === '/api/auth/me'));
assert.equal(recovery.w.document.getElementById('auth-screen').style.display, 'flex');
assert.equal(recovery.c.AccountBoundary.blocked(), false, 'Sign-in is available only in the new unauthenticated realm');
recovery.login('C'); await tick();
assert.equal(recovery.c.AccountBoundary.capture(), 'C');
assert.equal(recovery.secure.get('ped_scribe_token'), 'token-C');
assert.equal(recovery.c.AccountBoundary.needsSignIn(), false);
} finally { recovery.close(); }
} finally { fresh.close(); }
// The stale realm's recovery also preserves B; its in-flight native call is not undone.
a.w.document.querySelector('#account-recovery button').click();
assert.equal(a.reloads(), 1);
assert.equal(a.c.AccountBoundary.read().generation, sharedB.generation);
assert.equal(a.secure.get('ped_scribe_token'), 'token-A');
} finally { a.close(); }
});
test('passive enter rejects another published owner; recovery navigation requires confirmed realm-local storage', async () => {
for (const mode of ['throw', 'unconfirmed', 'read']) {
const b = await browser();
try {
b.storageMessage({ owner: 'B', generation: 'new-B', signedOut: false });
assert.equal(b.c.AccountBoundary.enter({ id: 'A' }), false);
assert.equal(b.c.AccountBoundary.read().generation, 'new-B');
const proto = Object.getPrototypeOf(b.w.sessionStorage), setItem = proto.setItem, getItem = proto.getItem;
proto.setItem = function(key, value) {
if (key === 'ped_signin_required' && mode !== 'read') { if (mode === 'throw') throw new Error('denied'); return; }
return setItem.call(this, key, value);
};
proto.getItem = function(key) {
if (key === 'ped_signin_required' && mode === 'read') throw new Error('readback denied');
return getItem.call(this, key);
};
const recovery = b.w.document.querySelector('#account-recovery button');
recovery.click(); assert.equal(b.reloads(), 0);
assert.match(b.w.document.querySelector('#account-recovery p').textContent, /Cannot safely reload/);
proto.setItem = setItem; proto.getItem = getItem;
recovery.click(); assert.equal(b.reloads(), 1);
assert.equal(b.c.AccountBoundary.read().generation, 'new-B');
} finally { b.close(); }
}
});
function readAloud(b) {
b.script('public/js/app.js');
b.c.userFeatures = { read_aloud: true };
const card = b.w.document.createElement('div');
card.className = 'card';
card.innerHTML = '<p id="speech-a">Synthetic A private note</p><p id="speech-next">Synthetic newer note</p><button data-action="speak" data-target="speech-a">Read</button>';
b.w.document.body.append(card);
const audio = [], spoken = [], revoked = [];
let urls = 0, cancels = 0;
b.c.URL.createObjectURL = () => 'blob:synthetic-' + ++urls;
b.c.URL.revokeObjectURL = url => revoked.push(url);
b.c.Audio = class {
constructor(url) { this.url = url; audio.push(this); }
play() { this.playing = true; return b.c.playResult || Promise.resolve(); }
pause() { this.playing = false; this.paused = true; }
};
b.c.SpeechSynthesisUtterance = function(text) { this.text = text; };
b.c.speechSynthesis = { speak(utterance) { spoken.push(utterance); }, cancel() { cancels++; } };
const tts = () => ({ ...response({}), headers: { get: () => 'synthetic' }, blob: async () => new b.c.Blob(['synthetic audio']) });
return { audio, spoken, revoked, tts, cancels: () => cancels };
}
test('actual TTS account abort and delayed blob cannot speak captured A via browser fallback', async () => {
for (const stage of ['request', 'blob', 'abort-without-freeze']) {
const b = await browser();
try {
b.owner('A'); const speech = readAloud(b), late = deferred();
b.c.api = (url, init) => {
if (stage === 'blob') return Promise.resolve({ ...speech.tts(), blob: () => late.promise });
if (stage === 'request') init.signal.addEventListener('abort', () => late.reject(new b.c.DOMException('account changed', 'AbortError')));
return late.promise;
};
b.c.speakText('speech-a'); await tick();
if (stage === 'abort-without-freeze') late.reject(new b.c.DOMException('canceled', 'AbortError'));
else b.c.AccountBoundary.freeze();
if (stage === 'blob') late.resolve(new b.c.Blob(['late A']));
await tick();
assert.equal(speech.spoken.length, 0, stage + ': abort is never a browser fallback');
assert.equal(speech.audio.length, 0, stage);
} finally { b.close(); }
}
});
test('actual account freeze stops already-playing server audio and browser synthesis and revokes URLs', async () => {
for (const mode of ['audio', 'synthesis']) {
const b = await browser();
try {
b.owner('A'); const speech = readAloud(b);
b.c.api = async () => { if (mode === 'synthesis') throw new Error('synthetic TTS outage'); return speech.tts(); };
b.c.speakText('speech-a'); await tick();
if (mode === 'audio') assert.equal(speech.audio[0].playing, true);
else assert.equal(speech.spoken[0].text, 'Synthetic A private note');
const before = speech.cancels();
b.c.AccountBoundary.freeze();
assert.ok(speech.cancels() > before, 'Freeze must cancel synthesis even if navigation is canceled');
assert.equal(b.c.currentlyReadingId, null);
if (mode === 'audio') {
assert.equal(speech.audio[0].paused, true);
assert.deepEqual(speech.revoked, [speech.audio[0].url]);
}
} finally { b.close(); }
}
});
test('actual TTS playback generation fences stale fetch, blob, play rejection and ended callbacks without stopping newer audio', async () => {
for (const stage of ['request', 'blob', 'play', 'ended']) {
const b = await browser();
try {
b.owner('A'); const speech = readAloud(b), late = deferred();
b.c.api = () => stage === 'request' ? late.promise : Promise.resolve({ ...speech.tts(), ...(stage === 'blob' ? { blob: () => late.promise } : {}) });
if (stage === 'play') b.c.playResult = late.promise;
b.c.speakText('speech-a'); await tick();
const ended = speech.audio[0]?.onended;
b.c.playResult = undefined;
b.c.api = async () => speech.tts();
b.c.speakText('speech-next'); await tick();
const latest = speech.audio.at(-1);
assert.equal(latest.playing, true);
const before = speech.cancels();
if (stage === 'request') late.resolve(speech.tts());
if (stage === 'blob') late.resolve(new b.c.Blob(['late A']));
if (stage === 'play') late.reject(new Error('late play rejection'));
if (stage === 'ended') ended();
await tick();
assert.equal(speech.audio.at(-1), latest);
assert.equal(latest.playing, true);
assert.equal(speech.cancels(), before);
assert.equal(speech.spoken.length, 0);
assert.equal(b.c.currentlyReadingId, 'speech-next');
b.c.stopReading();
assert.ok(speech.revoked.includes(latest.url));
} finally { b.close(); }
}
});
test('fresh explicit sign-in recovery permits SSO only after the existing cookie-only 401 preflight', async () => {
const b = await browser({ native: true, session: { ped_signin_required: '1' }, local: {
ped_account_boundary_v1: JSON.stringify({ owner: 'B', generation: 'new-B', signedOut: false })
} });
try {
b.secure.set('ped_scribe_token', 'token-A'); await b.auth('A');
assert.ok(!b.calls.some(call => call.url === '/api/auth/me'));
b.calls.length = 0;
b.c.api = async url => response({}, url === '/api/auth/me' ? 401 : 200);
await b.sso().done;
assert.deepEqual(b.calls.map(call => call.url), ['/api/auth/logout', '/api/auth/me']);
assert.equal(b.calls[1].init.headers, undefined);
assert.equal(b.c.AccountBoundary.needsSignIn(), false);
const callback = await browser({ ...b.snapshot(), native: true, url: 'https://offline.invalid/?sso=ok' });
try {
await callback.auth('C');
assert.equal(callback.c.AccountBoundary.capture(), 'C');
assert.equal(callback.c.getAuthHeaders().Authorization, undefined);
} finally { callback.close(); }
} finally { b.close(); }
});
test('biometric retrieval cannot start a new login after its captured shared generation changes', async () => {
const b = await browser({ native: true });
try {
await b.auth(null);
const retrieval = deferred();
b.c.Capacitor.Plugins.BiometricAuthNative = { authenticate: () => retrieval.promise };
b.secure.set('ped_bio_creds', JSON.stringify({ username: 'A@example.invalid', password: 'synthetic-password' }));
authForm(b, 'biometric');
b.storageMessage({ owner: 'B', generation: 'sibling-B', signedOut: false });
retrieval.resolve(); await tick();
assert.ok(!b.calls.some(call => call.url === '/api/auth/login'));
assert.equal(b.c.AccountBoundary.read().generation, 'sibling-B');
assert.equal(b.c.AccountBoundary.blocked(), true);
assert.equal(b.reloads(), 0);
} finally { b.close(); }
});
test('TTS 401/403/503 and feature revocation never fall back to browser speech', async () => {
for (const mode of [401, 403, 503, 'revoked']) {
const b = await browser();
try {
b.owner('A'); const speech = readAloud(b), late = deferred();
b.c.api = () => late.promise;
b.c.speakText('speech-a');
if (mode === 'revoked') b.c.userFeatures.read_aloud = false;
late.resolve(response({}, mode === 'revoked' ? 500 : mode));
await tick();
assert.equal(speech.spoken.length, 0);
assert.equal(speech.audio.length, 0);
} finally { b.close(); }
}
});
test('late passive bootstrap cannot reshow sign-in or overwrite a completed explicit login', async () => {
const b = await browser();
try {
const bootstrap = deferred();
b.c.api = async url => url === '/api/auth/me' ? bootstrap.promise : response({});
b.script('public/js/auth.js');
b.w.document.dispatchEvent(new b.c.Event('DOMContentLoaded'));
b.login('B'); await tick();
assert.equal(b.c.AccountBoundary.capture(), 'B');
bootstrap.resolve(response({ user: { id: 'A' } })); await tick();
assert.equal(b.c.AccountBoundary.capture(), 'B');
assert.equal(b.w.document.getElementById('auth-screen').style.display, 'none');
assert.equal(b.reloads(), 0);
} finally { b.close(); }
});
test('a transient passive owner read failure cannot be mistaken for absent ownership and republish A', async () => {
const b = await browser();
try {
b.storageMessage({ owner: 'B', generation: 'new-B', signedOut: false });
const proto = Object.getPrototypeOf(b.w.localStorage), getItem = proto.getItem;
let reads = 0;
proto.getItem = function(key) {
if (key === b.c.AccountBoundary.key && ++reads === 2) throw new Error('transient read failure');
return getItem.call(this, key);
};
assert.equal(b.c.AccountBoundary.enter({ id: 'A' }), false);
assert.equal(b.c.AccountBoundary.read().owner, 'B');
assert.equal(b.c.AccountBoundary.blocked(), true);
assert.equal(b.reloads(), 0);
} finally { b.close(); }
});