pediatric-ai-scribe-v3/test/clinical-release-integration.test.js
Daniel b992c6600b feat: admin can turn citations off
New setting clinical_assistant.citations_enabled (default true, admin checkbox).
With it off, retrieval, grounding and every other rule are unchanged — answers
are still built only from retrieved sources — but:

- buildSystemPrompt swaps only the citation block: the "cite factual claims with
  [1]" rules are replaced with "do not include citations, source numbers or
  bracketed markers", and the note that the sourcing requirement itself is
  unchanged. Grounding, scope, table formatting and tone rules are byte-identical
  between the two modes.
- The server strips any stray [n] the model emits anyway, from the stored answer
  rather than only the view, so saved chats and exports match what was shown.
- No sources are sent to the client at all, and the status endpoint reports the
  mode so the UI hides the Sources panel and gives its 330px column back to the
  chat instead of showing an empty rail.

Validated as a boolean in adminConfig, like the feature.* keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
2026-09-09 23:06:13 +02:00

139 lines
9.1 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 { normalizeMcpSearchResponse } = require('../src/utils/clinicalRetrieval');
const { savedChatPayload } = require('../src/utils/clinicalConversation');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const tick = async () => { for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); };
// Execute the actual static-route registration, without starting the application.
test('actual markup loads the existing local DOMPurify distribution through its vendor route', async t => {
const express = require('express');
const app = express();
const source = read('server.js');
const start = source.indexOf("app.use('/vendor/dompurify',");
const end = source.indexOf("app.use('/vendor/markdown-it',", start);
assert.ok(start >= 0 && end > start);
vm.runInNewContext(source.slice(start, end), { app, express, path, __dirname: root });
const server = app.listen(0, '127.0.0.1');
t.after(() => server.close());
await new Promise(resolve => server.on('listening', resolve));
const doc = new JSDOM(read('public/index.html')).window.document;
const scripts = [...doc.querySelectorAll('script[src]')].filter(script => /dompurify/i.test(script.src));
assert.equal(scripts.length, 1);
assert.equal(scripts[0].getAttribute('src'), '/vendor/dompurify/purify.min.js');
const response = await fetch('http://127.0.0.1:' + server.address().port + scripts[0].getAttribute('src'));
assert.equal(response.status, 200);
assert.match(response.headers.get('cache-control'), /max-age=3600/);
assert.equal(await response.text(), read('node_modules/dompurify/dist/purify.min.js'));
doc.defaultView.close();
});
test('native admin and assistant modules retain budget, table/source identity and safe live/saved/export rendering with or without DOMPurify', async t => {
const dom = new JSDOM('<div id="admin-tab">' + read('public/components/admin.html') + '</div><div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://app.example', runScripts: 'outside-only' });
const { window } = dom;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
const document = window.document;
const calls = [];
const limit = 2000;
let saved;
const rows = Array.from({ length: 65 }, (_, i) => '| Drug ' + i + ' | 2 mg/kg |');
const sources = normalizeMcpSearchResponse({ results: [{ id: 42, title: 'Synthetic', page_number: 7, excerpt: 'Table 1. Synthetic\n\n| Drug | Dose |\n|---|---|\n' + rows.join('\n') + '\n\nNote: Synthetic only.' }] });
assert.ok(sources[0].excerpt.length > 900);
const markdown = '| Drug | Dose | Sources |\n|---|---|---|\n| Synthetic | 2 mg/kg | [1] |\n\n<img src=x onerror="alert(1)"><svg onload="alert(2)"></svg><script>alert(3)</script>\n\n<a href="javascript:alert(4)">bad</a>';
const fetchMock = async (url, options = {}) => {
calls.push({ url, options });
if (url === '/api/clinical-assistant/chat/stream') return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: markdown, sources }) + '\n\n');
let data = { success: true, models: [] };
if (url === '/api/admin/config') {
data.config = [{ key: 'clinical_assistant.conversation_chars', value: '999999' }];
data.conversationBudget = { limit, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' };
}
else if (url === '/api/clinical-assistant/status') data.conversationChars = limit;
else if (url === '/api/clinical-assistant/chats' && options.method === 'POST') saved = savedChatPayload(JSON.parse(options.body));
else if (url === '/api/clinical-assistant/chats') data.chats = saved ? [{ id: 1, title: 'Synthetic saved chat' }] : [];
else if (url === '/api/clinical-assistant/chats/1') data.chat = { payload: saved };
else if (url.endsWith('/examples')) data.examples = [];
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
};
const values = { window, document, fetch: fetchMock, showToast() {}, getAuthHeaders: () => ({ 'Content-Type': 'application/json' }) };
const originals = Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(global, key)]);
Object.assign(global, values);
window.getAuthHeaders = values.getAuthHeaders;
window.showToast = values.showToast;
window.confirm = () => true;
window.marked = require('marked').marked;
window.matchMedia = () => ({ matches: true }); // Exercise the real inline export, without printing/downloading.
t.after(async () => {
await new Promise(resolve => setTimeout(resolve, 2000)); // let autosave + late fetch chains settle against this mock
for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(global, key, descriptor); else delete global[key]; }
window.close();
});
await import(pathToFileURL(path.join(root, 'public/js/admin.js')).href);
await import(pathToFileURL(path.join(root, 'public/js/clinicalAssistant.js')).href);
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
await tick();
assert.equal(document.querySelectorAll('#assistant-conversation-chars').length, 0);
assert.equal(document.getElementById('assistant-conversation-budget').value, '999999');
assert.equal(document.getElementById('assistant-conversation-budget').readOnly, false, 'the budget is admin-editable');
document.getElementById('btn-save-assistant-config').click();
await tick();
assert.equal(limit, 2000);
assert.equal(calls.filter(call => call.options.method === 'PUT').length, 8, 'one native admin initializer; prompts are not generic setting saves');
assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), true, 'the conversation budget is an admin-settable override');
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
await tick(); await tick(); await tick();
const input = document.getElementById('assistant-input');
input.value = 'x'.repeat(2001);
input.dispatchEvent(new window.Event('input'));
assert.match(document.getElementById('assistant-context-warning').textContent, /Sending is blocked/, 'the limit is known before the ask');
document.getElementById('assistant-form').dispatchEvent(new window.Event('submit', { cancelable: true }));
await tick();
assert.equal(input.value.length, 2001, 'over-budget draft retained');
assert.equal(calls.filter(call => call.url.endsWith('/chat/stream')).length, 0);
function assertSafe(element, purified) {
assert.ok(element);
assert.equal(element.querySelector('script, [onerror], [onload], a[href^="javascript:"]'), null);
if (purified) {
assert.equal(element.querySelectorAll('.assistant-table-scroll table tbody tr').length, 1);
const target = element.id === 'assistant-export-modal' ? '#ref-1-1' : '#assistant-source-1';
assert.equal(element.querySelector('.assistant-cite').getAttribute('href'), target);
assert.ok(document.querySelector(target), 'citation target exists in the displayed chat/export');
assert.match(element.querySelector('td').textContent, /Synthetic/);
} else {
assert.equal(element.querySelector('table, a, img, svg'), null, 'fallback is escaped text, not raw HTML');
assert.match(element.textContent, /onerror=/, 'unsafe markup remains inert text');
}
}
for (const purified of [false, true]) {
if (purified) window.eval(read('node_modules/dompurify/dist/purify.min.js'));
else delete window.DOMPurify;
document.getElementById('btn-assistant-clear').click();
input.value = 'Synthetic table question';
document.getElementById('assistant-form').dispatchEvent(new window.Event('submit', { cancelable: true }));
await tick();
assertSafe(document.querySelector('.assistant-msg.assistant .assistant-bubble'), purified);
await new Promise(resolve => setTimeout(resolve, 900)); // autosave debounce after the completed turn
assert.ok(saved, 'autosave persisted the completed turn');
assert.equal(saved.messages[1].content, markdown);
assert.equal(saved.sources[0].excerpt, sources[0].excerpt);
document.getElementById('btn-assistant-clear').click();
document.querySelector('[data-assistant-load-chat="1"]').click();
await tick();
assertSafe(document.querySelector('.assistant-msg.assistant .assistant-bubble'), purified);
const excerpt = document.querySelector('.assistant-source-excerpt p');
assert.equal(excerpt.textContent, sources[0].excerpt);
assert.equal(excerpt.style.whiteSpace, 'pre-wrap');
assert.match(excerpt.textContent, /Note: Synthetic only\.$/);
document.getElementById('btn-assistant-export-pdf').click();
assertSafe(document.querySelector('#assistant-export-modal'), purified);
document.querySelector('#assistant-export-modal #assistant-export-close').click();
}
});