344 lines
16 KiB
JavaScript
344 lines
16 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 { pathToFileURL } = require('node:url');
|
|
const { JSDOM } = require('jsdom');
|
|
const express = require('express');
|
|
const jwt = require('jsonwebtoken');
|
|
const sessions = require('../src/utils/sessions');
|
|
const platform = require('../src/utils/platform');
|
|
const root = path.join(__dirname, '..');
|
|
const secret = 'isolated-regression-secret';
|
|
|
|
// Execute real modules with explicit boundary fakes; no database or network access.
|
|
function load(file, mocks) {
|
|
const module = { exports: {} };
|
|
vm.runInNewContext(fs.readFileSync(path.join(root, file), 'utf8'), {
|
|
module, exports: module.exports, console,
|
|
process: { env: { JWT_SECRET: secret } },
|
|
require(name) {
|
|
assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name);
|
|
return mocks[name];
|
|
}
|
|
}, { filename: file });
|
|
return module.exports;
|
|
}
|
|
function response() {
|
|
return {
|
|
code: 200, body: null, cookies: [], cleared: [],
|
|
status(code) { this.code = code; return this; },
|
|
json(body) { this.body = body; return this; },
|
|
cookie(...args) { this.cookies.push(args); },
|
|
clearCookie(...args) { this.cleared.push(args); }
|
|
};
|
|
}
|
|
function endpoint(router, method, route) {
|
|
return router.stack.find(layer => layer.route && layer.route.path === route && layer.route.methods[method])
|
|
.route.stack.find(layer => layer.method === method).handle;
|
|
}
|
|
const authStub = { authMiddleware() {}, moderatorMiddleware() {} };
|
|
|
|
test('auth rejects missing last session and session-store errors, preserving valid web/mobile activity', async () => {
|
|
const token = jwt.sign({ userId: 7 }, secret, { expiresIn: '1h' });
|
|
for (const scenario of [
|
|
{ mode: 'missing', code: 401 },
|
|
{ mode: 'missing', mobile: true, code: 401 },
|
|
{ mode: 'error', code: 503 },
|
|
{ mode: 'valid', code: 200 },
|
|
{ mode: 'valid', method: 'POST', minutes: 11, refresh: true, code: 200 },
|
|
{ mode: 'valid', minutes: 11, code: 200 },
|
|
{ mode: 'valid', minutes: 1500, code: 401 },
|
|
{ mode: 'valid', minutes: 1500, mobile: true, method: 'POST', code: 200 }
|
|
]) {
|
|
const writes = [];
|
|
const db = {
|
|
async get(sql, params) {
|
|
if (sql.includes('FROM users')) return { id: 7, role: 'user', disabled: false };
|
|
if (sql.includes('COUNT')) return { count: 0 }; // Last session was revoked.
|
|
assert.equal(params[0], sessions.hashToken(token));
|
|
if (scenario.mode === 'error') throw new Error('private database diagnostic');
|
|
return scenario.mode === 'missing' ? null : {
|
|
id: 'session-7', last_activity: new Date(Date.now() - (scenario.minutes || 0) * 60000)
|
|
};
|
|
},
|
|
async run(sql) { writes.push(sql); }
|
|
};
|
|
const { authMiddleware } = load('src/middleware/auth.js', {
|
|
jsonwebtoken: jwt, '../db/database': db, '../utils/sessions': sessions, '../utils/platform': platform
|
|
});
|
|
const req = {
|
|
headers: scenario.mobile ? { 'x-client': 'mobile', authorization: 'Bearer ' + token } : {},
|
|
cookies: scenario.mobile ? {} : { ped_auth: token }, method: scenario.method || 'GET'
|
|
};
|
|
const res = response();
|
|
let next = 0;
|
|
await authMiddleware(req, res, () => next++);
|
|
assert.equal(res.code, scenario.code);
|
|
assert.equal(next, scenario.code === 200 ? 1 : 0);
|
|
assert.equal(res.cookies.length, scenario.refresh ? 1 : 0);
|
|
if (scenario.code === 200) assert.equal(req.sessionId, 'session-7');
|
|
assert.equal(writes.some(sql => sql.startsWith('UPDATE')),
|
|
scenario.method === 'POST' && scenario.code === 200 && scenario.minutes > 10);
|
|
if (scenario.mobile) assert.ok(!writes.some(sql => sql.startsWith('DELETE')));
|
|
if (scenario.mode === 'error') assert.equal(res.body.error, 'Authentication temporarily unavailable');
|
|
}
|
|
});
|
|
|
|
test('actual quiz write endpoints reject invalid types before all mutations and preserve valid defaults/types', async () => {
|
|
let storedType = 'multi';
|
|
const writes = [];
|
|
const router = load('src/routes/learningAdmin.js', {
|
|
express, '../middleware/auth': authStub, '../utils/embeddings': {},
|
|
'../utils/generatedImageLinks': require('../src/utils/generatedImageLinks'),
|
|
'../db/database': {
|
|
async get(sql) {
|
|
return sql.includes('MAX(') ? { mx: 0 } : { id: 9, question_text: 'Question', question_type: storedType };
|
|
},
|
|
async run(sql, params) { writes.push({ sql, params }); return { lastInsertRowid: 9 }; }
|
|
}
|
|
});
|
|
const create = endpoint(router, 'post', '/content/:contentId/questions');
|
|
const update = endpoint(router, 'put', '/questions/:id');
|
|
const badTypes = ['', null, false, 0, [], {}, 'MCQ', 'unknown', '\"><img src=x onerror=alert(1)>'];
|
|
for (const handler of [create, update]) {
|
|
for (const question_type of badTypes) {
|
|
writes.length = 0;
|
|
const res = response();
|
|
await handler({ params: { id: 9, contentId: 1 }, body: {
|
|
question_text: 'Q', question_type, options: [{ option_text: 'x' }]
|
|
} }, res);
|
|
assert.equal(res.code, 400, JSON.stringify(question_type));
|
|
assert.equal(writes.length, 0, 'Neither questions nor options may mutate');
|
|
}
|
|
for (const type of ['mcq', 'true_false', 'multi', undefined]) {
|
|
writes.length = 0;
|
|
const res = response();
|
|
await handler({ params: { id: 9, contentId: 1 }, body: {
|
|
question_text: 'Q', ...(type === undefined ? {} : { question_type: type })
|
|
} }, res);
|
|
assert.equal(res.code, 200);
|
|
assert.equal(writes[0].params[handler === create ? 2 : 1], type || (handler === create ? 'mcq' : storedType));
|
|
}
|
|
}
|
|
storedType = 'legacy-invalid';
|
|
writes.length = 0;
|
|
const res = response();
|
|
await update({ params: { id: 9 }, body: { question_text: 'Q' } }, res);
|
|
assert.equal(res.code, 400, 'Invalid legacy type must be explicitly corrected');
|
|
assert.equal(writes.length, 0);
|
|
});
|
|
|
|
test('actual quiz renderer contains malicious legacy metadata and preserves quiz input semantics', async () => {
|
|
const dom = new JSDOM('<div id="root"></div>');
|
|
global.window = dom.window;
|
|
global.document = dom.window.document;
|
|
window.DOMPurify = require('dompurify')(window);
|
|
try {
|
|
const { renderQuizQuestions } = await import(pathToFileURL(path.join(root, 'public/js/learningHub/quizRenderer.js')).href);
|
|
const malicious = '\"><span id="injected"></span><div onmouseover="alert(1)';
|
|
const types = ['mcq', 'true_false', 'multi', malicious];
|
|
document.getElementById('root').innerHTML = renderQuizQuestions(types.map((question_type, id) => ({
|
|
id, question_type, question_text: '<strong>Question</strong>', options: [{ id: id + 10, option_text: 'Answer' }]
|
|
})));
|
|
const questions = [...document.querySelectorAll('.lh-quiz-q')];
|
|
assert.equal(questions.length, 4);
|
|
assert.equal(document.querySelector('#injected, [onmouseover]'), null);
|
|
questions.forEach((el, i) => {
|
|
assert.equal(el.dataset.qtype, types[i]);
|
|
assert.equal(el.querySelector('input').type, i === 2 ? 'checkbox' : 'radio');
|
|
assert.equal(el.querySelector('input').name, (i === 2 ? 'lh-qm-' : 'lh-q-') + i);
|
|
assert.equal(el.querySelector('strong').textContent, 'Question');
|
|
});
|
|
} finally {
|
|
dom.window.close(); delete global.window; delete global.document;
|
|
}
|
|
});
|
|
|
|
function encounterServer() {
|
|
const rows = [];
|
|
const db = {
|
|
async getSetting() { return '7'; },
|
|
async get(sql, p) {
|
|
if (sql.includes('idempotency_key')) return rows.find(row => row.key === p[1]);
|
|
if (sql.includes('LOWER(label)')) return rows.find(row => row.label.toLowerCase() === p[1].toLowerCase());
|
|
if (sql.includes('SELECT id, version')) return rows.find(row => String(row.id) === String(p[0]));
|
|
throw new Error('Unexpected query: ' + sql);
|
|
},
|
|
async run(sql, p) {
|
|
if (sql.startsWith('INSERT')) {
|
|
const row = { id: rows.length + 1, label: p[1], transcript: p[3], note: p[4], key: p[7], version: 1 };
|
|
rows.push(row);
|
|
return { lastInsertRowid: row.id };
|
|
}
|
|
assert.ok(sql.startsWith('UPDATE'));
|
|
const versioned = sql.includes('version=$6');
|
|
const row = rows.find(row => String(row.id) === String(p[versioned ? 6 : 5]));
|
|
assert.ok(row);
|
|
Object.assign(row, { label: p[0], transcript: p[1], note: p[2] });
|
|
if (versioned) row.version = p[5];
|
|
return { changes: 1 };
|
|
}
|
|
};
|
|
const router = load('src/routes/encounters.js', {
|
|
express, '../db/database': db, '../middleware/auth': authStub,
|
|
'../utils/logger': { audit() {}, error() {} }, '../utils/crypto': { encryptString: value => value }
|
|
});
|
|
const save = endpoint(router, 'post', '/encounters/saved');
|
|
return { rows, async post(body) {
|
|
const res = response();
|
|
await save({ user: { id: 7 }, body }, res);
|
|
return res;
|
|
} };
|
|
}
|
|
|
|
function edBrowser(server, storage = {}) {
|
|
const dom = new JSDOM('<input id="ed-label"><div id="ed-transcript"></div>' +
|
|
'<div id="ed-stages-container"></div><button id="btn-ed-finalize"></button>' +
|
|
'<button id="btn-ed-save"></button><button id="btn-ed-new"></button>', {
|
|
url: 'https://isolated.invalid', runScripts: 'outside-only'
|
|
});
|
|
const w = dom.window;
|
|
for (const [key, value] of Object.entries(storage.session || {})) w.sessionStorage.setItem(key, value);
|
|
for (const [key, value] of Object.entries(storage.local || {})) w.localStorage.setItem(key, value);
|
|
w.showToast = () => {};
|
|
w.showBusy = () => { w.busy = true; };
|
|
w.hideBusy = () => { w.busy = false; };
|
|
w.HTMLElement.prototype.scrollIntoView = () => {};
|
|
w.getAuthHeaders = () => ({});
|
|
const pending = [];
|
|
w.fetch = (url, opts = {}) => {
|
|
assert.ok(['/api/encounters/saved', '/api/ed-encounters/finalize'].includes(url));
|
|
if (opts.method !== 'POST') return Promise.resolve({ json: async () => ({ encounters: server.rows }) });
|
|
return new Promise((resolve, reject) => pending.push({ url, body: JSON.parse(opts.body), resolve, reject }));
|
|
};
|
|
w.eval(fs.readFileSync(path.join(root, 'public/js/accountBoundary.js'), 'utf8'));
|
|
w.AccountBoundary.enter({ id: 7 }, true);
|
|
w.eval(fs.readFileSync(path.join(root, 'public/js/encounters.js'), 'utf8'));
|
|
// ED is a module in production: isolate its lexical state, keeping real event handlers.
|
|
w.eval('(function() {\n' + fs.readFileSync(path.join(root, 'public/js/ed-encounters.js'), 'utf8') + '\n})();');
|
|
w.document.dispatchEvent(new w.CustomEvent('tabChanged', { detail: { tab: 'ed' } }));
|
|
return {
|
|
w, pending, close: () => w.close(),
|
|
save(label) { w.document.getElementById('ed-label').value = label; w.document.getElementById('btn-ed-save').click(); },
|
|
newPatient() { w.document.getElementById('btn-ed-new').click(); },
|
|
snapshot() { return { session: { ...w.sessionStorage }, local: { ...w.localStorage } }; },
|
|
async complete(index = 0, loseResponse = false) {
|
|
const request = pending.splice(index, 1)[0];
|
|
assert.ok(request, 'Expected a save request');
|
|
const res = request.url === '/api/ed-encounters/finalize'
|
|
? { code: 200, body: { success: true, finalNote: 'Consolidated note', mdm: null } }
|
|
: await server.post(request.body);
|
|
if (loseResponse) request.reject(new Error('Simulated lost response'));
|
|
else request.resolve({ status: res.code, json: async () => res.body });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
return res;
|
|
}
|
|
};
|
|
}
|
|
|
|
const draftStorage = { local: { 'ped_ed_draft_v1:owner:7': JSON.stringify({
|
|
label: 'Patient A', state: { stage: 1, stages: [{ transcript: 'First transcript', note: 'First stage note' }], finalized: false }
|
|
}) } };
|
|
|
|
test('two new ED drafts get distinct UUIDs, preserving the first; same draft retry/reload keeps identity', async () => {
|
|
const server = encounterServer();
|
|
let browser = edBrowser(server, draftStorage);
|
|
try {
|
|
browser.save('Patient A');
|
|
const keyA = browser.pending[0].body.idempotency_key;
|
|
assert.match(keyA, /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/);
|
|
browser.save('Patient A');
|
|
assert.equal(browser.pending.length, 1, 'Double-click is suppressed');
|
|
await browser.complete(0, true);
|
|
assert.equal(browser.w._savedEncId_ed, undefined);
|
|
browser.save('Patient A');
|
|
assert.equal(browser.pending[0].body.idempotency_key, keyA);
|
|
await browser.complete(0, true);
|
|
const storage = browser.snapshot();
|
|
browser.close();
|
|
browser = edBrowser(server, storage);
|
|
browser.save('Patient A');
|
|
assert.equal(browser.pending[0].body.idempotency_key, keyA);
|
|
assert.equal((await browser.complete()).code, 200, 'Lost-response retry bypasses same-row label conflict');
|
|
assert.equal(server.rows.length, 1);
|
|
assert.equal(browser.w._savedEncId_ed, 1);
|
|
const savedStorage = browser.snapshot();
|
|
browser.close();
|
|
browser = edBrowser(server, savedStorage);
|
|
browser.save('Patient A');
|
|
assert.equal(String(browser.pending[0].body.id), '1');
|
|
await browser.complete();
|
|
const first = { ...server.rows[0] };
|
|
assert.equal(first.transcript, 'First transcript');
|
|
assert.equal(first.note, 'First stage note');
|
|
browser.newPatient();
|
|
browser.save('Patient B');
|
|
assert.equal(browser.pending[0].body.id, null);
|
|
assert.notEqual(browser.pending[0].body.idempotency_key, keyA);
|
|
await browser.complete();
|
|
assert.equal(server.rows.length, 2);
|
|
assert.deepEqual(server.rows[0], first);
|
|
assert.equal(browser.w._savedEncId_ed, 2);
|
|
} finally { browser.close(); }
|
|
});
|
|
|
|
test('delayed old ED save cannot assign its ID or unlock a new patient save', async () => {
|
|
const server = encounterServer();
|
|
const browser = edBrowser(server);
|
|
try {
|
|
browser.save('Old patient');
|
|
browser.newPatient();
|
|
browser.save('New patient');
|
|
assert.equal(browser.pending.length, 2, 'New identity can save while old request is pending');
|
|
await browser.complete(0);
|
|
assert.equal(browser.w._savedEncId_ed, null);
|
|
assert.equal(browser.w.sessionStorage.getItem('_savedEncId_ed:owner:7'), null);
|
|
browser.save('New patient');
|
|
assert.equal(browser.pending.length, 1, 'Old callback must not release new save lock');
|
|
await browser.complete();
|
|
assert.equal(browser.w._savedEncId_ed, 2);
|
|
assert.equal(browser.w.sessionStorage.getItem('_savedEncId_ed:owner:7'), '2');
|
|
} finally { browser.close(); }
|
|
});
|
|
|
|
test('backend rejects ambiguous legacy ED key reuse without overwriting its first row', async () => {
|
|
const server = encounterServer();
|
|
const body = { label: 'Legacy A', enc_type: 'ed', idempotency_key: 'ed-draft-new', generated_note: 'Original' };
|
|
assert.equal((await server.post(body)).code, 200);
|
|
const first = { ...server.rows[0] };
|
|
assert.equal((await server.post({ ...body, label: 'Legacy B', generated_note: 'Other patient' })).code, 409);
|
|
assert.equal((await server.post(body)).code, 409, 'Ambiguous legacy retries require loading by ID');
|
|
assert.deepEqual(server.rows, [first]);
|
|
assert.equal((await server.post({ ...body, id: 1, generated_note: 'Explicit update' })).code, 200);
|
|
assert.equal(server.rows[0].note, 'Explicit update');
|
|
});
|
|
|
|
test('ED final save reuses draft UUID after lost response; delayed finalize cannot save a reset patient', async () => {
|
|
const server = encounterServer();
|
|
const browser = edBrowser(server, draftStorage);
|
|
try {
|
|
browser.save('Patient A');
|
|
const key = browser.pending[0].body.idempotency_key;
|
|
await browser.complete(0, true);
|
|
browser.w.document.getElementById('btn-ed-finalize').click();
|
|
await browser.complete();
|
|
assert.equal(browser.pending[0].body.idempotency_key, key);
|
|
assert.equal(browser.pending[0].body.status, 'final');
|
|
await browser.complete();
|
|
assert.equal(server.rows.length, 1);
|
|
assert.equal(server.rows[0].note, 'Consolidated note');
|
|
} finally { browser.close(); }
|
|
const delayed = edBrowser(server, draftStorage);
|
|
try {
|
|
delayed.w.document.getElementById('btn-ed-finalize').click();
|
|
assert.equal(delayed.pending.length, 1);
|
|
delayed.newPatient();
|
|
assert.equal(delayed.w.busy, false, 'Reset clears the old finalization busy indicator');
|
|
await delayed.complete();
|
|
assert.equal(delayed.pending.length, 0, 'Old finalize must not initiate a save with new identity');
|
|
assert.equal(delayed.w._savedEncId_ed, null);
|
|
assert.equal(delayed.w.document.getElementById('ed-final-note-card'), null);
|
|
} finally { delayed.close(); }
|
|
});
|