From baab83e50ea49669d48eb88403103c83efd22817 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 6 Sep 2026 07:44:08 +0200 Subject: [PATCH] fix: enforce sessions, escape quiz metadata and isolate ED drafts --- public/js/ed-encounters.js | 11 +- public/js/encounters.js | 16 +- public/js/learningHub/quizRenderer.js | 2 +- src/middleware/auth.js | 8 +- src/routes/encounters.js | 46 +-- src/routes/learningAdmin.js | 9 +- test/session-quiz-ed-regressions.test.js | 341 +++++++++++++++++++++++ 7 files changed, 396 insertions(+), 37 deletions(-) create mode 100644 test/session-quiz-ed-regressions.test.js diff --git a/public/js/ed-encounters.js b/public/js/ed-encounters.js index d411035..494ad5b 100644 --- a/public/js/ed-encounters.js +++ b/public/js/ed-encounters.js @@ -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'); diff --git a/public/js/encounters.js b/public/js/encounters.js index 19ba7ca..8a28274 100644 --- a/public/js/encounters.js +++ b/public/js/encounters.js @@ -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) diff --git a/public/js/learningHub/quizRenderer.js b/public/js/learningHub/quizRenderer.js index 0df9607..f3eb155 100644 --- a/public/js/learningHub/quizRenderer.js +++ b/public/js/learningHub/quizRenderer.js @@ -10,7 +10,7 @@ export function renderQuizQuestions(questions) { var nameAttr = isMulti ? 'lh-qm-' + q.id : 'lh-q-' + q.id; var hint = isMulti ? '
Select all that apply
' : ''; - return '
' + + return '
' + '
' + 'Q' + (idx + 1) + '' + '' + typeLabel + '' + diff --git a/src/middleware/auth.js b/src/middleware/auth.js index b7fd4ad..fca0cbc 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -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(); diff --git a/src/routes/encounters.js b/src/routes/encounters.js index 3087a74..8e8090e 100644 --- a/src/routes/encounters.js +++ b/src/routes/encounters.js @@ -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( diff --git a/src/routes/learningAdmin.js b/src/routes/learningAdmin.js index fae2a47..39162e4 100644 --- a/src/routes/learningAdmin.js +++ b/src/routes/learningAdmin.js @@ -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 diff --git a/test/session-quiz-ed-regressions.test.js b/test/session-quiz-ed-regressions.test.js new file mode 100644 index 0000000..bae8356 --- /dev/null +++ b/test/session-quiz-ed-regressions.test.js @@ -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', '\">']; + 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('
'); + 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 = '\">
' + + '
' + + '', { + 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(); } +});