Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 59s
Forgejo Docker Build / Root app tests (push) Successful in 50s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The MIME type was taken on trust here. Anything at all could be posted as image/png: it passed the size and base64 checks, was stored in the saved chat, and was handed to a provider as a data URI. Documents and S3 uploads have always been sniffed by fileType.js; this was the one upload path that was not. Now sniffed with the same helper, so there is one idea of what a PNG looks like. A PHP payload, a shell script, an ELF or PE binary, a zip, or a real PDF labelled image/png are all refused with a message that says what is wrong. What this does not claim: bytes hidden after a valid PNG header still make a valid PNG, and no sniffer can promise otherwise. The protection is that the file is never executed and never served as anything but an image. Existing fixtures used buffers of 0x07 as stand-in images, which are correctly refused now. They carry real file headers instead — a fixture should be the thing it claims to be, exactly like a real upload. Also adds the deck theme system: five palettes in assets/deck-themes.json, render_pptx.py rebinding its palette from the theme rather than hardcoding it, the theme carried on the deck and validated against the same catalogue the renderer reads, a picker on the generate form, and PUT /my-resources/:id/theme to re-skin a stored deck with no model call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
198 lines
13 KiB
JavaScript
198 lines
13 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 express = require('express');
|
|
const { JSDOM } = require('jsdom');
|
|
const { marked } = require('marked');
|
|
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 quiet = { log() {}, warn() {}, error() {}, info() {} };
|
|
// Real file headers, padded. Attachments are sniffed now, so a buffer of 0x07
|
|
// is not an image and is correctly refused — a fixture has to be the thing it
|
|
// claims to be, exactly like a real upload.
|
|
const HEADERS = {
|
|
'image/png': Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'),
|
|
'image/jpeg': Buffer.from('ffd8ffe000104a4649460001', 'hex'),
|
|
'image/webp': Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP')])
|
|
};
|
|
function image(mimeType, length) {
|
|
const head = HEADERS[mimeType];
|
|
const total = Math.max(length, head.length);
|
|
return Buffer.concat([head, Buffer.alloc(total - head.length, 7)]).toString('base64');
|
|
}
|
|
const canonical = (length, fill = 7) => image('image/png', length);
|
|
const png = { dataBase64: canonical(16), mimeType: 'image/png', name: 'chest.png' };
|
|
const jpeg = { dataBase64: image('image/jpeg', 32), mimeType: 'image/jpeg', name: 'knee.jpeg' };
|
|
const asset = '/api/generated-images/12345678-1234-1234-1234-123456789abc';
|
|
|
|
function server(options = {}) {
|
|
const calls = { ai: [], search: [], writes: [], images: [] };
|
|
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.translate_provider') return 'libretranslate';
|
|
return options.model || null;
|
|
},
|
|
async get(sql, params) {
|
|
if (sql.includes('COUNT')) return { cnt: 0 };
|
|
if (sql.includes('FROM clinical_assistant_chats') && params) return saved && params[0] === 1 && params[1] === 7 ? { id: params[0], title: saved[1], payload: saved[2] } : null;
|
|
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 }; },
|
|
async query(sql, params) { return { rows: (params[0] || []).map(id => ({ id })) }; }
|
|
};
|
|
const ai = async (messages, settings) => {
|
|
calls.ai.push({ messages, settings });
|
|
return { content: 'Complete supported answer. [1]', 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() {}, warn() {} },
|
|
'../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') },
|
|
'../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) },
|
|
'../utils/generatedImages': { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return { jobId: 'synthetic', status: 'done', success: true }; }, async enqueue() { return { success: true, jobId: 'synthetic', status: 'pending' }; } }) },
|
|
'../utils/generatedImageLinks': require('../src/utils/generatedImageLinks'),
|
|
'../utils/visionTool': require('../src/utils/visionTool'),
|
|
'../utils/modelVision': { supportsVision: async () => null },
|
|
'../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 = calls.health || []; 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, TextEncoder,
|
|
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('savedChatPayload persists bounded attachments and the generated image; invalid input is rejected before any write', () => {
|
|
const messages = [{ role: 'user', content: 'Question', attachments: [png, jpeg] }, { role: 'assistant', content: 'Answer [1]', sources: [{ number: 1, title: 'S' }] }];
|
|
const payload = policy.savedChatPayload({ messages, lastAnswer: 'Answer [1]', sources: [], generatedImage: asset });
|
|
assert.deepEqual(payload.messages[0].attachments, [png, jpeg], 'normalized roundtrip incl. name');
|
|
assert.equal(payload.generatedImage, asset, 'sidebar image now persists');
|
|
assert.equal(payload.messages[1].attachments, undefined, 'absent attachments stay absent');
|
|
|
|
const invalid = [
|
|
[{ dataBase64: png.dataBase64, mimeType: 'image/svg+xml' }],
|
|
[{ dataBase64: 'not base64!!!', mimeType: 'image/png' }],
|
|
[{ dataBase64: canonical(5 * 1024 * 1024 + 1), mimeType: 'image/png' }],
|
|
[png, jpeg, png, jpeg, png],
|
|
'nope'
|
|
];
|
|
for (const attachments of invalid) {
|
|
assert.throws(() => policy.savedChatPayload({ messages: [{ role: 'user', content: 'Q', attachments }] }), error => error.statusCode === 400);
|
|
}
|
|
const longName = { dataBase64: png.dataBase64, mimeType: png.mimeType, name: 'n'.repeat(400) };
|
|
const trimmed = policy.savedChatPayload({ messages: [{ role: 'user', content: 'Q', attachments: [longName] }] });
|
|
assert.equal(trimmed.messages[0].attachments[0].name.length, 255, 'names clip at 255 characters');
|
|
|
|
for (const generatedImage of ['data:image/svg+xml;base64,PHN2Zz4=', 'javascript:alert(1)']) {
|
|
assert.throws(() => policy.savedChatPayload({ messages: [], generatedImage }), error => error.statusCode === 400);
|
|
}
|
|
assert.throws(() => policy.savedChatPayload({ messages: [{ role: 'user', content: 'x'.repeat(policy.MAX_SAVED_CHAT_BYTES) }] }), error => error.statusCode === 413);
|
|
});
|
|
|
|
test('POST /chats stores and reopens attachments; the same id updates in place instead of duplicating', async () => {
|
|
const app = server();
|
|
const body = { messages: [{ role: 'user', content: 'Question with image', attachments: [png] }, { role: 'assistant', content: 'Answer [1]' }], lastAnswer: 'Answer [1]', sources: [] };
|
|
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[0].attachments)), [png]);
|
|
const updated = await app.request('post', '/clinical-assistant/chats', { id: 1, messages: [{ role: 'user', content: 'Edited', attachments: [jpeg] }], lastAnswer: '' });
|
|
assert.equal(updated.statusCode, 200);
|
|
assert.equal(updated.body.id, 1);
|
|
assert.ok(app.calls.writes.some(sql => /UPDATE clinical_assistant_chats/.test(sql)), 'update path used');
|
|
const foreign = await app.request('post', '/clinical-assistant/chats', { id: 1, messages: [{ role: 'user', content: 'x' }] }, 8);
|
|
assert.equal(foreign.statusCode, 404, 'updates are owner-bound');
|
|
const missing = await app.request('post', '/clinical-assistant/chats', { id: 99, messages: [{ role: 'user', content: 'x' }] });
|
|
assert.equal(missing.statusCode, 404);
|
|
const badId = await app.request('post', '/clinical-assistant/chats', { id: 'not-a-number', messages: [{ role: 'user', content: 'x' }] });
|
|
assert.equal(badId.statusCode, 400);
|
|
});
|
|
|
|
test('client restores attachments as thumbnails and saves them plus the generated image', t => {
|
|
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
|
|
const window = dom.window;
|
|
window.eval(read('public/js/accountBoundary.js'));
|
|
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
|
|
window.marked = marked;
|
|
window.DOMPurify = require('dompurify')(window);
|
|
window.matchMedia = () => ({ matches: true });
|
|
const saves = [];
|
|
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
|
|
setTimeout() {}, clearTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
|
|
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: src => '<img src="' + src + '">' }),
|
|
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
|
|
fetchAssistantImageJobs: async () => ({ success: true, jobs: [{ jobId: 'g1', imageUrl: asset }] }),
|
|
saveAssistantChat: async body => { saves.push(JSON.parse(JSON.stringify(policy.savedChatPayload(body)))); return { success: true, id: 1 }; } };
|
|
vm.createContext(context);
|
|
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
|
|
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
|
|
}
|
|
t.after(() => window.close());
|
|
context.bindEvents();
|
|
context.appendMessage('user', 'Question with image', [], [], false, { attachments: [png] });
|
|
let thumbs = window.document.querySelector('.assistant-msg.user .assistant-message-attachments');
|
|
assert.ok(thumbs, 'thumbnail strip rendered');
|
|
assert.ok(thumbs.querySelector('img').getAttribute('src').startsWith('data:image/png;base64,'));
|
|
assert.match(thumbs.textContent, /chest\.png/);
|
|
context.appendMessage('assistant', 'Answer [1].', [{ number: 1, title: 'S', page: 2 }]);
|
|
context.lastAnswer = 'Answer [1].';
|
|
context.lastGeneratedImageSrc = asset;
|
|
context.scheduleAutosave();
|
|
return context.performAutosave().then(() => {
|
|
assert.deepEqual(saves[0].messages[0].attachments, [png]);
|
|
assert.equal(saves[0].generatedImage, asset);
|
|
context.restoreSavedChat(saves[0]);
|
|
thumbs = window.document.querySelector('.assistant-msg.user .assistant-message-attachments');
|
|
assert.ok(thumbs, 'attachments restored after reload');
|
|
// The user's image library lives inside the Create image popup.
|
|
window.document.getElementById('btn-assistant-create-image').click();
|
|
var waitFor = function(remaining) {
|
|
return new Promise(function(resolve) { setImmediate(resolve); }).then(function() {
|
|
if (window.document.querySelector('#create-image-history img')) return Promise.resolve();
|
|
if (remaining <= 0) return Promise.resolve();
|
|
return waitFor(remaining - 1);
|
|
});
|
|
};
|
|
return waitFor(12).then(function() {
|
|
const galleryWrap = window.document.querySelector('#create-image-history');
|
|
const galleryImg = window.document.querySelector('#create-image-history img');
|
|
assert.ok(galleryImg && galleryImg.getAttribute('src') === asset, 'generated image restored into the image history popup; popup=' + (window.document.getElementById('assistant-create-image-modal') ? 'open' : 'missing') + ' history=' + (galleryWrap ? galleryWrap.innerHTML.slice(0, 120) : 'missing'));
|
|
});
|
|
assert.equal(context.messages[0].content, 'Question with image', 'raw transcript canonical');
|
|
});
|
|
});
|