138 lines
9 KiB
JavaScript
138 lines
9 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');
|
|
document.getElementById('btn-save-assistant-config').click();
|
|
await tick();
|
|
assert.equal(limit, 2000);
|
|
assert.equal(calls.filter(call => call.options.method === 'PUT').length, 7, 'one native admin initializer; prompts and ENV budget are not generic setting saves');
|
|
assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), true, 'the conversation budget is now 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();
|
|
}
|
|
});
|