fix: enforce sessions, escape quiz metadata and isolate ED drafts
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m47s
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m47s
This commit is contained in:
parent
2942d4f6b3
commit
baab83e50e
7 changed files with 396 additions and 37 deletions
|
|
@ -445,6 +445,7 @@
|
|||
var modelEl = document.getElementById('ed-model-select');
|
||||
var selectedModel = modelEl ? modelEl.value : '';
|
||||
|
||||
var encounterState = _state;
|
||||
showBusy('Consolidating note + generating MDM...');
|
||||
fetch('/api/ed-encounters/finalize', {
|
||||
method: 'POST',
|
||||
|
|
@ -461,6 +462,7 @@
|
|||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (_state !== encounterState) return;
|
||||
if (!data.success) { hideBusy(); showToast(data.error || 'Finalize failed', 'error'); return; }
|
||||
_state.finalNote = data.finalNote || '';
|
||||
_state.mdm = data.mdm;
|
||||
|
|
@ -484,7 +486,6 @@
|
|||
generated_note: composeFinalNoteForSave(_state.finalNote, _state.mdm),
|
||||
partial_data: partial,
|
||||
status: 'final',
|
||||
idempotency_key: 'ed-final-' + Date.now(),
|
||||
onSaved: function(id) {
|
||||
window._savedEncId_ed = id;
|
||||
try { sessionStorage.setItem('_savedEncId_ed', id); } catch(e) {}
|
||||
|
|
@ -497,7 +498,10 @@
|
|||
hideBusy();
|
||||
}
|
||||
})
|
||||
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
||||
.catch(function(err) {
|
||||
if (_state !== encounterState) return;
|
||||
hideBusy(); showToast(err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function composeFinalNoteForSave(noteText, mdm) {
|
||||
|
|
@ -530,7 +534,6 @@
|
|||
generated_note: lastNote,
|
||||
partial_data: partial,
|
||||
status: 'draft',
|
||||
idempotency_key: 'ed-draft-' + (window._savedEncId_ed || 'new'),
|
||||
onSaved: function(id) {
|
||||
window._savedEncId_ed = id;
|
||||
try { sessionStorage.setItem('_savedEncId_ed', id); } catch(e) {}
|
||||
|
|
@ -547,6 +550,7 @@
|
|||
// ── Reset / new patient ──────────────────────────────────────────────
|
||||
function resetEncounter() {
|
||||
_state = freshState();
|
||||
hideBusy();
|
||||
setVal('ed-label', '');
|
||||
setVal('ed-age', '');
|
||||
setVal('ed-gender', '');
|
||||
|
|
@ -561,6 +565,7 @@
|
|||
hideEl('ed-mdm-card');
|
||||
window._savedEncId_ed = null;
|
||||
try { sessionStorage.removeItem('_savedEncId_ed'); } catch(e) {}
|
||||
if (typeof window.resetIdempotencyKey === 'function') window.resetIdempotencyKey('ed');
|
||||
clearLocal();
|
||||
updateBadge();
|
||||
showToast('ED encounter cleared for new patient', 'info');
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ function setupEncountersModule() {
|
|||
try { sessionStorage.removeItem(key); } catch(e) {}
|
||||
}
|
||||
|
||||
window.resetIdempotencyKey = resetIdempotencyKey;
|
||||
|
||||
// Restore saved encounter IDs from sessionStorage (survive page refresh, cleared on tab close)
|
||||
function restoreSavedEncounterIds() {
|
||||
['encounter','dictation','ed','hospital','chart','wellvisit','sickvisit','soap'].forEach(function(t) {
|
||||
|
|
@ -116,11 +118,13 @@ function setupEncountersModule() {
|
|||
if (!opts.label || !opts.label.trim()) { showToast('Enter a patient label first', 'error'); return; }
|
||||
var type = opts.enc_type || 'encounter';
|
||||
// Prevent duplicate saves from double-click
|
||||
if (_savingInProgress[type]) { showToast('Save in progress…', 'info'); return; }
|
||||
_savingInProgress[type] = true;
|
||||
var identityKey = getIdempotencyKey(type);
|
||||
if (_savingInProgress[type] === identityKey) { showToast('Save in progress…', 'info'); return; }
|
||||
_savingInProgress[type] = identityKey;
|
||||
// Optimistic locking: send the version we last saw. Server returns 409
|
||||
// if the encounter was updated elsewhere (another tab, another device).
|
||||
var body = Object.assign({}, opts);
|
||||
if (!body.idempotency_key) body.idempotency_key = identityKey;
|
||||
if (opts.id && window._encounterVersions && window._encounterVersions[opts.id] != null) {
|
||||
body.expected_version = window._encounterVersions[opts.id];
|
||||
}
|
||||
|
|
@ -131,6 +135,8 @@ function setupEncountersModule() {
|
|||
})
|
||||
.then(function(r) { return r.json().then(function(d) { d._status = r.status; return d; }); })
|
||||
.then(function(data) {
|
||||
// A completed save must not attach the previous patient's ID after New Patient.
|
||||
if (getIdempotencyKey(type) !== identityKey) return;
|
||||
_savingInProgress[type] = false;
|
||||
if (data._status === 409) {
|
||||
showToast('Someone else edited this encounter. Reload to see the latest version.', 'error');
|
||||
|
|
@ -153,7 +159,11 @@ function setupEncountersModule() {
|
|||
showToast(data.error || 'Save failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { _savingInProgress[type] = false; showToast('Save failed', 'error'); });
|
||||
.catch(function() {
|
||||
if (getIdempotencyKey(type) !== identityKey) return;
|
||||
_savingInProgress[type] = false;
|
||||
showToast('Save failed', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
// Load list and refresh all displays (settings page + open popovers)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function renderQuizQuestions(questions) {
|
|||
var nameAttr = isMulti ? 'lh-qm-' + q.id : 'lh-q-' + q.id;
|
||||
var hint = isMulti ? '<div class="lh-quiz-multi-hint">Select all that apply</div>' : '';
|
||||
|
||||
return '<div class="lh-quiz-q" data-qid="' + q.id + '" data-qtype="' + q.question_type + '">' +
|
||||
return '<div class="lh-quiz-q" data-qid="' + q.id + '" data-qtype="' + esc(q.question_type) + '">' +
|
||||
'<div class="lh-quiz-q-header">' +
|
||||
'<span class="lh-quiz-q-num">Q' + (idx + 1) + '</span>' +
|
||||
'<span class="lh-quiz-q-type">' + typeLabel + '</span>' +
|
||||
|
|
|
|||
|
|
@ -53,11 +53,7 @@ async function authMiddleware(req, res, next) {
|
|||
var tHash = hashToken(token);
|
||||
var session = await db.get('SELECT id, last_activity FROM user_sessions WHERE token_hash = ?', [tHash]);
|
||||
if (!session) {
|
||||
// Pre-migration user? If they have no sessions at all, allow through
|
||||
var sessCount = await db.get('SELECT COUNT(*) as count FROM user_sessions WHERE user_id = ?', [user.id]);
|
||||
if (parseInt(sessCount.count) > 0) {
|
||||
return res.status(401).json({ error: 'Session revoked' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Session revoked' });
|
||||
} else {
|
||||
req.sessionId = session.id;
|
||||
var now = Date.now();
|
||||
|
|
@ -100,7 +96,7 @@ async function authMiddleware(req, res, next) {
|
|||
}
|
||||
}
|
||||
} catch (sessErr) {
|
||||
// Fail open — table may not exist yet during migration
|
||||
return res.status(503).json({ error: 'Authentication temporarily unavailable' });
|
||||
}
|
||||
|
||||
next();
|
||||
|
|
|
|||
|
|
@ -105,38 +105,40 @@ router.post('/encounters/saved', async function(req, res) {
|
|||
res.json({ success: true, id: id, version: newVersion });
|
||||
logger.audit(req.user.id, 'encounter_save', 'Saved encounter id:' + id, req, { category: 'clinical' });
|
||||
} else {
|
||||
var dup = idempotency_key ? await db.get(
|
||||
'SELECT id FROM saved_encounters WHERE user_id = $1 AND idempotency_key = $2',
|
||||
[req.user.id, idempotency_key]
|
||||
) : null;
|
||||
// Old ED clients shared this key across patients; never overwrite its row.
|
||||
if (dup && idempotency_key === 'ed-draft-new') {
|
||||
return res.status(409).json({ error: 'Legacy draft key conflict. Reload the app and load the existing encounter or start a new patient.' });
|
||||
}
|
||||
// Enforce unique label per user (within active/non-expired encounters)
|
||||
if (label && label.trim()) {
|
||||
var labelDup = await db.get(
|
||||
"SELECT id FROM saved_encounters WHERE user_id = $1 AND LOWER(label) = LOWER($2) AND expires_at > NOW()",
|
||||
[req.user.id, label.trim()]
|
||||
);
|
||||
if (labelDup) {
|
||||
if (labelDup && (!dup || labelDup.id !== dup.id)) {
|
||||
return res.status(409).json({ error: 'An encounter with this label already exists. Use a unique label or load the existing one.' });
|
||||
}
|
||||
}
|
||||
// Check for duplicate via idempotency_key
|
||||
if (idempotency_key) {
|
||||
var dup = await db.get(
|
||||
'SELECT id FROM saved_encounters WHERE user_id = $1 AND idempotency_key = $2',
|
||||
[req.user.id, idempotency_key]
|
||||
// Retry the same draft even if its original response was lost.
|
||||
if (dup) {
|
||||
// Update existing instead of creating duplicate
|
||||
await db.run(
|
||||
'UPDATE saved_encounters SET label=$1, transcript=$2, generated_note=$3, partial_data=$4, status=$5, updated_at=NOW() WHERE id=$6 AND user_id=$7',
|
||||
[
|
||||
label || 'Untitled',
|
||||
cryptoUtil.encryptString(transcript || ''),
|
||||
cryptoUtil.encryptString(generated_note || ''),
|
||||
cryptoUtil.encryptString(partialToString(partial_data)),
|
||||
status || 'active',
|
||||
dup.id, req.user.id
|
||||
]
|
||||
);
|
||||
if (dup) {
|
||||
// Update existing instead of creating duplicate
|
||||
await db.run(
|
||||
'UPDATE saved_encounters SET label=$1, transcript=$2, generated_note=$3, partial_data=$4, status=$5, updated_at=NOW() WHERE id=$6 AND user_id=$7',
|
||||
[
|
||||
label || 'Untitled',
|
||||
cryptoUtil.encryptString(transcript || ''),
|
||||
cryptoUtil.encryptString(generated_note || ''),
|
||||
cryptoUtil.encryptString(partialToString(partial_data)),
|
||||
status || 'active',
|
||||
dup.id, req.user.id
|
||||
]
|
||||
);
|
||||
logger.audit(req.user.id, 'encounter_save', 'Saved encounter id:' + dup.id, req, { category: 'clinical' });
|
||||
return res.json({ success: true, id: dup.id });
|
||||
}
|
||||
logger.audit(req.user.id, 'encounter_save', 'Saved encounter id:' + dup.id, req, { category: 'clinical' });
|
||||
return res.json({ success: true, id: dup.id });
|
||||
}
|
||||
// Create new
|
||||
var result = await db.run(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ function slugify(text) {
|
|||
}
|
||||
|
||||
var ALLOWED_SLUG_TABLES = ['learning_categories', 'learning_content'];
|
||||
var ALLOWED_QUESTION_TYPES = ['mcq', 'true_false', 'multi'];
|
||||
|
||||
async function uniqueSlug(table, base) {
|
||||
if (ALLOWED_SLUG_TABLES.indexOf(table) === -1) throw new Error('Invalid table for slug generation');
|
||||
|
|
@ -230,6 +231,8 @@ router.post('/content/:contentId/questions', async function(req, res) {
|
|||
try {
|
||||
var { question_text, question_type, explanation, options } = req.body;
|
||||
if (!question_text) return res.status(400).json({ error: 'Question text required' });
|
||||
if (question_type === undefined) question_type = 'mcq';
|
||||
if (!ALLOWED_QUESTION_TYPES.includes(question_type)) return res.status(400).json({ error: 'Invalid question type' });
|
||||
|
||||
// Get max sort_order
|
||||
var maxOrder = await db.get('SELECT COALESCE(MAX(sort_order), -1) as mx FROM learning_questions WHERE content_id = ?', [req.params.contentId]);
|
||||
|
|
@ -237,7 +240,7 @@ router.post('/content/:contentId/questions', async function(req, res) {
|
|||
|
||||
var result = await db.run(
|
||||
'INSERT INTO learning_questions (content_id, question_text, question_type, explanation, sort_order) VALUES (?, ?, ?, ?, ?)',
|
||||
[req.params.contentId, question_text, question_type || 'mcq', explanation || '', sortOrder]
|
||||
[req.params.contentId, question_text, question_type, explanation || '', sortOrder]
|
||||
);
|
||||
|
||||
var questionId = result.lastInsertRowid;
|
||||
|
|
@ -263,10 +266,12 @@ router.put('/questions/:id', async function(req, res) {
|
|||
if (!q) return res.status(404).json({ error: 'Question not found' });
|
||||
|
||||
var { question_text, question_type, explanation, options } = req.body;
|
||||
if (question_type === undefined) question_type = q.question_type;
|
||||
if (!ALLOWED_QUESTION_TYPES.includes(question_type)) return res.status(400).json({ error: 'Invalid question type' });
|
||||
|
||||
await db.run(
|
||||
'UPDATE learning_questions SET question_text = ?, question_type = ?, explanation = ? WHERE id = ?',
|
||||
[question_text || q.question_text, question_type || q.question_type, explanation !== undefined ? explanation : q.explanation, q.id]
|
||||
[question_text || q.question_text, question_type, explanation !== undefined ? explanation : q.explanation, q.id]
|
||||
);
|
||||
|
||||
// Replace options if provided
|
||||
|
|
|
|||
341
test/session-quiz-ed-regressions.test.js
Normal file
341
test/session-quiz-ed-regressions.test.js
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
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': {},
|
||||
'../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/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: 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'), 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'), '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(); }
|
||||
});
|
||||
Loading…
Reference in a new issue