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 express = require('express'); const { JSDOM, requestInterceptor } = require('jsdom'); const { pathToFileURL } = require('node:url'); const policy = require('../src/utils/clinicalConversation'); const answer = require('../src/utils/clinicalAnswer'); const root = path.join(__dirname, '..'); const read = file => fs.readFileSync(path.join(root, file), 'utf8'); const png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII='; const quiet = { log() {}, warn() {}, error() {}, info() {} }; function server(options = {}) { const calls = { ai: [], search: [], writes: [], images: [], health: [] }; let saved; const db = { async getSetting(key) { if (options.dbError) throw Error('private diagnostic'); if (key.endsWith('conversation_chars')) return options.legacyLimit ?? 'broken'; if (key === 'clinical_assistant.image_behavior') return options.imageBehavior ?? null; return options.model || null; }, async get(sql, params) { if (sql.includes('COUNT')) return { cnt: 0 }; return saved && params[1] === 7 ? { id: 1, title: saved[1], payload: saved[2] } : null; }, async run(sql, params) { calls.writes.push(sql); saved = params; return { lastInsertRowid: 1 }; } }; const ai = async (messages, settings) => { calls.ai.push({ messages, settings }); return { content: options.emptyAI ? '' : 'Complete supported answer. [1]', finishReason: Object.hasOwn(options, 'finishReason') ? options.finishReason : 'stop', model: 'synthetic' }; }; const source = { number: 1, title: 'Synthetic source', excerpt: 'Synthetic reference.', page: 9 }; const mocks = { express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db, '../middleware/auth': { authMiddleware() {} }, './auth': { __sendEmail: async () => false }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai }, '../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} }, '../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') }, '../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) }, '../utils/generatedImages': options.images || { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return { jobId: 'synthetic', status: 'done', success: true }; }, async enqueue(owner, workflow, input) { calls.images.push({ prompt: require('../src/utils/clinicalPrompts').imagePromptForCanvas(input.prompt, options.imageBehavior) + '\nRequested layout: auto.' }); return { success: true, jobId: 'synthetic', status: 'pending' }; } }) }, '../utils/generatedImageLinks': require('../src/utils/generatedImageLinks'), '../utils/imageTool': require('../src/utils/imageTool'), '../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'), '../utils/clinicalMcpClient': { async semanticSearch(query) { calls.search.push(query); return {}; }, async getMcpHealth() { calls.health.push('health'); return {}; } }, '../utils/clinicalRetrieval': { cleanSourceExcerpt: require('../src/utils/clinicalRetrieval').cleanSourceExcerpt, normalizeMcpSearchResponse: () => options.noSources ? [] : [source], normalizeMcpMultimodalResponse: () => [], dedupeSources: value => value, isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] }, '../utils/clinicalAnswer': answer, '../utils/clinicalConversation': policy, '../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'), '../utils/patientTakehome': require('../src/utils/patientTakehome') }; const module = { exports: {} }; vm.runInNewContext(read('src/routes/clinicalAssistant.js'), { module, exports: module.exports, console: quiet, Buffer, Map, process: { env: { CLINICAL_ASSISTANT_MCP_WARMUP: 'false', CLINICAL_ASSISTANT_CONVERSATION_CHARS: options.limit, LITELLM_API_BASE: 'http://synthetic.invalid' } }, setTimeout() {}, require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name); return mocks[name]; } }); async function request(method, endpoint, body, userId = 7) { const handler = module.exports.stack.find(layer => layer.route && layer.route.path === endpoint && layer.route.methods[method]).route.stack.find(layer => layer.method === method).handle; const res = { statusCode: 200, headers: {}, events: '', body: null, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, setHeader(k, v) { this.headers[k] = v; }, write(text) { this.events += text; }, end() {}, flushHeaders() {} }; await handler({ body, params: { id: 1 }, user: { id: userId } }, res); return res; } return { calls, request }; } test('conversation budget validates exact boundaries, Unicode, malformed roles and configuration', () => { assert.equal(policy.conversationLimit(null), 120000); for (const value of ['oops', '999', '1000001', '1.5', NaN, false, [1000], {}]) assert.throws(() => policy.conversationLimit(value)); const history = [{ role: 'user', content: '😀\n'.repeat(333) }]; assert.equal(policy.checkConversation(history, 'x', 1000).budget.used, 1000); assert.throws(() => policy.checkConversation(history, 'xx', 1000), error => error.statusCode === 413); for (const history of [null, {}, [{ role: 'system', content: 'x' }], [{ role: 'user', content: 123 }]]) { assert.throws(() => policy.checkConversation(history, 'x', 1000), error => error.statusCode === 400); } }); test('real chat and stream endpoints reject before all paid/retrieval calls; invalid ENV closes access', async () => { for (const endpoint of ['/clinical-assistant/chat', '/clinical-assistant/chat/stream']) { const app = server({ limit: '1000' }); const result = await app.request('post', endpoint, { message: 'hello', history: [{ role: 'user', content: 'x'.repeat(1001) }] }); assert.equal(result.statusCode, 413); assert.equal(result.body.code, 'CONVERSATION_LIMIT'); assert.equal(result.events, ''); assert.equal(app.calls.ai.length + app.calls.search.length, 0); } for (const options of [{ limit: '999' }, { limit: 'broken' }, { limit: ' ' }]) { const app = server(options); const result = await app.request('post', '/clinical-assistant/chat/stream', { message: 'Question', history: [] }); assert.equal(result.statusCode, 503); assert.equal(app.calls.ai.length + app.calls.search.length, 0); assert.doesNotMatch(JSON.stringify(result.body), /private diagnostic/); } }); test('real accepted route retains early turns and late corrections in rewrite and answer, with fresh retrieval', async () => { const app = server(); const history = Array.from({ length: 12 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: 'Turn ' + i + '\n' })); history[0].content = 'Original age: 3 years.\n' + 'detail '.repeat(250) + '\nCORRECTION: age 3 months, not years.\n| Dose | Unit |\n|---|---|\n| 0.25 | mg/kg |'; const result = await app.request('post', '/clinical-assistant/chat', { message: 'What about monitoring?', history }); assert.equal(result.statusCode, 200); assert.equal(app.calls.search.length, 1); assert.equal(app.calls.ai.length, 2); for (const call of app.calls.ai) { assert.ok(call.messages[1].content.includes(history[0].content)); assert.ok(call.messages[1].content.includes('Turn 11')); } assert.match(app.calls.ai[1].messages[1].content, /prior AI output is not evidence/); const second = await app.request('post', '/clinical-assistant/chat', { message: 'Explain the differential diagnosis and diagnostic workup in detail for this presentation.', history }); assert.equal(second.statusCode, 200); assert.equal(app.calls.search.length, 2); const long = await server({ noSources: true }).request('post', '/clinical-assistant/chat', { message: 'x'.repeat(4001), history: [] }); assert.equal(long.statusCode, 200, 'The old independent 4,000-character clipping boundary is gone'); }); test('real save/reopen keeps 101 turns, Markdown, Unicode and full source metadata; the sidebar image persists', async () => { const app = server(); const content = ' Preserve indent\n\n| Item | Unit |\n|---|---|\n| 0.25 | mg/kg |\n' + '保留'.repeat(6500); const sources = Array.from({ length: 31 }, (_, index) => ({ number: index + 1, title: 'Title ' + index, excerpt: content, resource: 'source-' + index, page: index + 1 })); const messages = Array.from({ length: 101 }, (_, index) => ({ role: index % 2 ? 'assistant' : 'user', content: index === 0 ? content : 'Turn ' + index, sources: index === 1 ? sources : [] })); const body = { messages, sources, lastAnswer: content, generatedImage: png }; const saved = await app.request('post', '/clinical-assistant/chats', body); assert.equal(saved.statusCode, 200); const reopened = await app.request('get', '/clinical-assistant/chats/:id', {}); assert.deepEqual(JSON.parse(JSON.stringify(reopened.body.chat.payload.messages)), messages); assert.equal(reopened.body.chat.payload.lastAnswer, content); assert.equal(reopened.body.chat.payload.generatedImage, png, 'The sidebar image persists with the chat'); assert.equal(reopened.body.chat.payload.sources.length, 31); assert.equal((await app.request('get', '/clinical-assistant/chats/:id', {}, 8)).statusCode, 404); const oversized = await app.request('post', '/clinical-assistant/chats', { messages: [{ role: 'user', content: 'x'.repeat(policy.MAX_SAVED_CHAT_BYTES) }] }); assert.equal(oversized.statusCode, 413); const invalidImage = await app.request('post', '/clinical-assistant/chats', { messages: [], generatedImage: 'data:image/svg+xml;base64,PHN2Zz4=' }); assert.equal(invalidImage.statusCode, 400); for (const generatedImage of ['data:image/png;base64,SGVsbG8=', png.replace('image/png', 'image/jpeg')]) { assert.equal((await app.request('post', '/clinical-assistant/chats', { messages: [], generatedImage })).statusCode, 400); } assert.equal(app.calls.writes.length, 1, 'Rejected saves must not insert truncated records'); }); test('handoff route is gone; save/download wording covers the budget limit', async () => { const app = server(); await assert.rejects(app.request('post', '/clinical-assistant/handoff', { history: [{ role: 'user', content: 'Known facts.' }] }), /route/, 'route layer no longer exists'); assert.throws(() => policy.checkConversation([{ role: 'user', content: 'x' }], 'y'.repeat(1000), 1000), error => error.statusCode === 413 && !/handoff/i.test(error.message)); }); function browserUI(options = {}) { const dom = new JSDOM('
' + read('public/components/assistant.html') + '
', { url: 'https://example.test/', resources: { interceptors: [requestInterceptor(request => { assert.equal(new URL(request.url).pathname, '/css/assistant.css'); return new Response(read('public/css/assistant.css'), { headers: { 'Content-Type': 'text/css' } }); })] } }); const calls = { stream: [], save: [] }; const escapeHtml = text => String(text).replace(/&/g, '&').replace(//g, '>'); const context = { window: dom.window, document: dom.window.document, navigator: dom.window.navigator, console: quiet, AbortController, TextDecoder, TextEncoder, URL, Blob, crypto: require("node:crypto").webcrypto, setTimeout() {}, showToast() {}, escapeHtml, escapeAttr: escapeHtml, renderAssistantMarkdown: text => escapeHtml(text), renderSourcesList: () => '', ...options.renderers, EMPTY_PROMPT_SETS: [[]], createAssistantExporter: () => ({ invalidate() {}, exportAnswerPdf() {} }), createAssistantImageStore: () => ({ renderGeneratedImage: src => '', clear() {} }), isImageRequest: () => false, fetchAssistantStatus: async () => ({ success: true, conversationChars: options.limit || 120000 }), fetchAssistantExamples: async () => ({}), fetchSavedAssistantChats: async () => ({ success: true, chats: [] }), openAssistantStream: async payload => { calls.stream.push(payload); if (options.stream) return options.stream(payload); return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Complete response.', sources: [] }) + '\n\n'); }, saveAssistantChat: async payload => { calls.save.push(payload); return { success: true }; } }; vm.createContext(context); vm.runInContext(read('public/js/clinicalAssistant.js').replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, ''), context); context.bindEvents(); context.conversationChars = options.limit || 120000; return { context, document: dom.window.document, calls, dom }; } test('actual UI blocks over-budget input without clearing draft/history', async () => { const ui = browserUI({ limit: 1000 }); ui.context.restoreSavedChat({ messages: [{ role: 'user', content: 'x'.repeat(999) }] }); ui.document.getElementById('assistant-input').value = 'xx'; await ui.context.onAsk(); assert.equal(ui.calls.stream.length, 0); assert.equal(ui.context.messages.length, 1); assert.equal(ui.document.getElementById('assistant-input').value, 'xx'); assert.equal(ui.document.getElementById('assistant-context-warning').hidden, false); ui.dom.window.close(); }); test('actual UI sends complete prior history exactly once and saves the sidebar image', async () => { const ui = browserUI(); const messages = Array.from({ length: 20 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: 'Turn ' + i + (i === 0 ? 'x'.repeat(1500) : ''), sources: [] })); ui.context.restoreSavedChat({ messages, lastAnswer: 'Previous answer.', generatedImage: png }); ui.document.getElementById('assistant-input').value = 'New question'; await ui.context.onAsk(); assert.equal(ui.calls.stream[0].history.length, 20); assert.equal(ui.calls.stream[0].history[0].content, messages[0].content); assert.equal(ui.context.messages.length, 22); assert.equal(ui.context.messages[20].content, 'New question'); assert.equal(ui.document.getElementById('assistant-input').value, ''); ui.context.performAutosave(); assert.equal(ui.calls.save[0].generatedImage, png); assert.equal(ui.calls.save[0].messages.length, 22); assert.equal(ui.context.messages.length, 22); ui.dom.window.close(); }); test('actual UI preserves draft on authoritative server rejection and ignores a late cancelled response', async () => { const ui = browserUI({ stream: () => new Response(JSON.stringify({ error: 'Limit reached', code: 'CONVERSATION_LIMIT', budget: { limit: 1000 } }), { status: 413 }) }); ui.document.getElementById('assistant-input').value = 'Draft must survive'; await ui.context.onAsk(); assert.equal(ui.context.messages.length, 0); assert.equal(ui.document.getElementById('assistant-input').value, 'Draft must survive'); assert.equal(ui.context.conversationChars, 1000); ui.dom.window.close(); let release; const delayed = browserUI({ stream: () => new Promise(resolve => { release = resolve; }) }); delayed.document.getElementById('assistant-input').value = 'Pending question'; const request = delayed.context.onAsk(); delayed.context.clearConversation(); release(new Response('event: done\ndata: {"success":true,"answer":"Old answer"}\n\n')); await request; assert.equal(delayed.context.messages.length, 0); assert.doesNotMatch(delayed.document.getElementById('assistant-messages').textContent, /Old answer/); delayed.dom.window.close(); }); test('dose follow-up rewrites from the drug/diagnosis AND a separate late age correction', async () => { for (const endpoint of ['/clinical-assistant/chat', '/clinical-assistant/chat/stream']) { const app = server(); const history = [ { role: 'user', content: 'Discuss acyclovir for suspected neonatal herpes in a three-month-old.' }, { role: 'assistant', content: 'Confirm the age before deciding on therapy.' }, { role: 'user', content: 'Correction: three weeks, not three months.' } ]; const response = await app.request('post', endpoint, { history, message: 'dose?' }); assert.equal(response.statusCode, 200); assert.equal(app.calls.ai.length, 2, 'Rewrite must not shortcut to only the latest turn'); for (const turn of history) assert.ok(app.calls.ai[0].messages[1].content.includes(turn.content)); assert.ok(app.calls.ai[0].messages[1].content.includes('dose?')); assert.equal(app.calls.search[0], 'Complete supported answer. [1]', 'Retrieval uses the full-history rewrite result'); } }); test('real save/reopen and UI renderer contain malicious legacy numbers without changing citation identity', async () => { const renderers = { ...await import(pathToFileURL(path.join(root, 'public/js/assistant/citations.js')).href), ...await import(pathToFileURL(path.join(root, 'public/js/assistant/sources.js')).href) }; const ui = browserUI({ renderers }); ui.dom.window.DOMPurify = require('dompurify')(ui.dom.window); const app = server(); const malicious = '1">