fix: enforce sessions, escape quiz metadata and isolate ED drafts
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m47s

This commit is contained in:
Daniel 2026-09-06 07:44:08 +02:00
parent 2942d4f6b3
commit baab83e50e
7 changed files with 396 additions and 37 deletions

View file

@ -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');

View file

@ -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)

View file

@ -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>' +

View file

@ -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();

View file

@ -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(

View file

@ -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

View 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(); }
});