1122 lines
61 KiB
JavaScript
1122 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/learningHub.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 });
|
|
if (url === '/api/admin/learning/ai-generate') return response({ success: false });
|
|
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><input id="lh-ai-model" value="fake"><input id="lh-ai-topic" value="Synthetic topic"><button id="btn-lh-ai-generate">Generate</button>';
|
|
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();
|
|
controls.querySelector('#btn-lh-ai-generate').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', '/api/admin/learning/ai-generate']) 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(); }
|
|
});
|