Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
**The regex is gone.** The route ran a pattern over the user's message and enqueued an image from the answer text when the model had not called the tool. It was a compatibility path for models without tool calling and it did more harm than good: it decided in English only, it could not see the conversation, and "image summary" fell through it while reading as an obvious image request to the model itself — which was measured, not assumed. A second and worse decision-maker sitting behind the first. Whether a message deserves a picture is now the model's call, made from the tool description, which is the only place it ever belonged. **Lending eyes.** The same shape, for a different capability. When someone attaches a photograph and the chat model cannot accept image input, the attachment was either refused by the provider or silently dropped — an answer about a picture nobody had looked at, which is worse than a refusal. The chat model is now offered look_at_image beside the image tool and decides when to use it. The attachment goes to clinical_assistant.vision_model, whose description comes back as a tool result, and the chat model answers in its own voice with its own sources. Only the seeing is delegated; the clinical reasoning stays with the model an administrator chose. The seeing model is told to report and not to diagnose, because it has a picture and no context and an opinion from it would carry weight it has not earned. Delegation triggers only on an explicit supports_vision: false from the gateway. An unknown is left alone — most of a roster reports nothing, and treating silence as blindness would route good models through a detour. The capability lookup moved to its own module, is cached for five minutes because it runs on exactly the requests that are already slowest, and is never inferred from the model id. liteLLMBaseUrl moved from the admin route to litellm.js, where the other gateway helpers live. The new setting is guarded like the slide reviewer: a model the gateway calls text-only cannot be saved as the one that looks at images. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
416 lines
38 KiB
JavaScript
416 lines
38 KiB
JavaScript
// Run only with scripts/test-generated-images.sh: disposable internal-network PG + private S3.
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { Pool } = require('pg');
|
|
const { createImageService, requestKey } = require('../src/utils/generatedImages');
|
|
const { createStorage, inspect } = require('../src/utils/generatedImageStorage');
|
|
const links = require('../src/utils/generatedImageLinks');
|
|
const { savedChatPayload } = require('../src/utils/clinicalConversation');
|
|
const revisions = require('../src/utils/promptRevisions');
|
|
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII=', 'base64');
|
|
if (!process.env.GENERATED_IMAGES_TEST_DB || new URL(process.env.GENERATED_IMAGES_TEST_DB).hostname !== 'test-pg' || new URL(process.env.GENERATED_IMAGES_TEST_DB).pathname !== '/image_lane') throw Error('Use the disposable test script, never an application database');
|
|
const pool = new Pool({ connectionString: process.env.GENERATED_IMAGES_TEST_DB });
|
|
const db = { pool, query: (sql, params) => pool.query(sql, params), async all(sql, params) { return (await pool.query(sql, params)).rows; }, async get(sql, params) { return (await pool.query(sql, params)).rows[0]; } };
|
|
let paid = 0;
|
|
const generate = async () => { paid++; return inspect(png); };
|
|
const storage = createStorage();
|
|
const service = () => createImageService({ db, storage, generate });
|
|
let image;
|
|
test.before(async () => {
|
|
await pool.query('CREATE TABLE users(id INTEGER PRIMARY KEY); INSERT INTO users VALUES(101),(102),(103); CREATE TABLE app_settings(key TEXT PRIMARY KEY,value TEXT,updated_at TIMESTAMPTZ DEFAULT NOW()); CREATE TABLE learning_content(id SERIAL PRIMARY KEY,body TEXT,published BOOLEAN DEFAULT false);');
|
|
for (const migration of ['1777700000000_add-prompt-revisions','1777800000000_generated-images','1777900000000_image-context']) {
|
|
let sql; require('../migrations/' + migration).up({ sql: value => { sql = value; } }); await pool.query(sql);
|
|
}
|
|
await pool.query("INSERT INTO app_settings(key,value) VALUES('learning_hub.image_model','synthetic-learning-image'),('clinical_assistant.image_model','synthetic-clinical-image')");
|
|
});
|
|
test.after(async () => { storage.close(); await pool.end(); });
|
|
test('private S3 asset, encrypted durable snapshot, idempotency and owner/workflow isolation', async () => {
|
|
const jobs = service(); const before = paid;
|
|
const input = { prompt: 'Synthetic flowchart comparison', layout: 'portrait' };
|
|
image = await jobs.enqueue(101, 'clinical_assistant', input, 'first');
|
|
const dup = await jobs.enqueue(101, 'clinical_assistant', input, 'first'); assert.equal(dup.jobId, image.jobId);
|
|
await assert.rejects(jobs.enqueue(101, 'clinical_assistant', { prompt: 'different' }, 'first'), e => e.statusCode === 409);
|
|
const row = await db.get('SELECT * FROM generated_image_jobs WHERE id=$1', [image.jobId]);
|
|
assert.match(row.prompt_cipher, /^enc1:/); assert.ok(!row.prompt_cipher.includes(input.prompt)); assert.equal(row.budget, 32000); assert.equal(row.model, 'synthetic-clinical-image');
|
|
await jobs.tick(); assert.equal(paid, before + 1);
|
|
const done = await jobs.get(image.jobId, 101, 'clinical_assistant'); assert.equal(done.status, 'done'); assert.equal(done.imageUrl, '/api/generated-images/' + image.jobId);
|
|
assert.deepEqual((await jobs.asset(image.jobId, { id: 101 })).bytes, png);
|
|
await assert.rejects(jobs.get(image.jobId, 102, 'clinical_assistant'), e => e.statusCode === 404);
|
|
await assert.rejects(jobs.get(image.jobId, 101, 'learning_hub'), e => e.statusCode === 404);
|
|
await assert.rejects(jobs.asset(image.jobId, { id: 102, role: 'admin' }), e => e.statusCode === 404);
|
|
assert.equal((await db.get('SELECT staged_bytes FROM generated_image_jobs WHERE id=$1', [image.jobId])).staged_bytes, null);
|
|
const anonymous = await fetch('http://test-s3:9000/generated-images/assets/' + image.jobId); assert.equal(anonymous.status, 403);
|
|
});
|
|
test('saved chat keeps exact body/citations, validates owned asset and durable job references', async () => {
|
|
const body = '## Exact\nDose [3, 1].\n| A | Source |\n| --- | --- |\n| 5 mg | [1] |';
|
|
const sources = [{ number: 3, page: 19, title: 'Synthetic three' }, { number: 1, page: 4, title: 'Synthetic one' }];
|
|
const payload = savedChatPayload({ messages: [{ role: 'assistant', content: body, sources, imageJobs: [image] }], lastAnswer: body, sources, generatedImage: '/api/generated-images/' + image.jobId });
|
|
await links.validateChat(db, payload, 101);
|
|
assert.equal(payload.messages[0].content, body); assert.deepEqual(payload.sources, sources);
|
|
assert.deepEqual(JSON.parse(JSON.stringify(payload)), payload);
|
|
await assert.rejects(links.validateChat(db, payload, 102), e => e.statusCode === 403);
|
|
});
|
|
test('Learning has independent model/prompt revision and authenticated current-publication grants; clinical UUID cannot be published', async () => {
|
|
const jobs = service();
|
|
const revision = await revisions.mutate(db, 'learning_hub.image_behavior', { action: 'save', value: 'Synthetic learning instructions.', expectedRevision: 0, actor: 101 });
|
|
const job = await jobs.enqueue(101, 'learning_hub', { prompt: 'Learning diagram' }, 'learning');
|
|
const row = await db.get('SELECT * FROM generated_image_jobs WHERE id=$1', [job.jobId]);
|
|
assert.equal(row.prompt_revision, revision.revision); assert.equal(row.model, 'synthetic-learning-image');
|
|
assert.match(require('../src/utils/crypto').decryptString(row.prompt_cipher), /Synthetic learning instructions/);
|
|
const changed = await revisions.mutate(db, 'learning_hub.image_behavior', { action: 'save', value: 'Second synthetic Learning instructions.', expectedRevision: revision.revision, actor: 101 });
|
|
const restored = await revisions.mutate(db, 'learning_hub.image_behavior', { action: 'restore', revisionId: revision.revision, expectedRevision: changed.revision, actor: 101 });
|
|
assert.equal(restored.value, revision.value);
|
|
assert.equal((await revisions.read(db, 'learning_hub.image_behavior', restored.revision)).restoredFrom, revision.revision);
|
|
assert.equal((await db.get('SELECT prompt_revision FROM generated_image_jobs WHERE id=$1', [job.jobId])).prompt_revision, revision.revision);
|
|
await jobs.tick();
|
|
const id = (await db.query("INSERT INTO learning_content(body,published) VALUES('',false) RETURNING id")).rows[0].id;
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
await assert.rejects(links.validateLearning(client, '<img src="/api/generated-images/' + image.jobId + '">', 101, id), e => e.statusCode === 403);
|
|
const ids = await links.validateLearning(client, '<img src="/api/generated-images/' + job.jobId + '">', 101, id);
|
|
await links.setLinks(client, id, ids); await client.query('COMMIT');
|
|
} finally { client.release(); }
|
|
await assert.rejects(jobs.asset(job.jobId, { id: 102, role: 'user' }), e => e.statusCode === 404);
|
|
assert.deepEqual((await jobs.asset(job.jobId, { id: 102, role: 'moderator' })).bytes, png);
|
|
await db.query('UPDATE learning_content SET published=true WHERE id=$1', [id]);
|
|
assert.deepEqual((await jobs.asset(job.jobId, { id: 102, role: 'user' })).bytes, png);
|
|
await db.query('UPDATE learning_content SET published=false WHERE id=$1', [id]);
|
|
await assert.rejects(jobs.asset(job.jobId, { id: 102, role: 'user' }), e => e.statusCode === 404);
|
|
await db.query('DELETE FROM generated_image_links WHERE content_id=$1', [id]);
|
|
await assert.rejects(jobs.asset(job.jobId, { id: 102, role: 'moderator' }), e => e.statusCode === 404);
|
|
await assert.rejects(links.validateLearning(db, '/api/generated-images/' + job.jobId, 102, id), e => e.statusCode === 403);
|
|
await assert.rejects(db.query('INSERT INTO generated_image_links VALUES($1,$2)', [image.jobId, id]), /Only Learning assets/);
|
|
await assert.rejects(db.query("UPDATE generated_image_jobs SET workflow='learning_hub' WHERE id=$1", [image.jobId]), /immutable/);
|
|
});
|
|
test('restart resumes queued/storage stages, but never retries ambiguous paid stages; lease fencing and SKIP LOCKED are native', async () => {
|
|
const jobs = service(); const before = paid;
|
|
const queued = await jobs.enqueue(101, 'clinical_assistant', { prompt: 'Queued across restart' }, 'restart');
|
|
await service().tick(); assert.equal((await jobs.get(queued.jobId,101,'clinical_assistant')).status, 'done');
|
|
const unknown = await jobs.enqueue(101, 'clinical_assistant', { prompt: 'Crash during provider request' }, 'unknown');
|
|
const old = await jobs.claim(); assert.equal(old.id, unknown.jobId);
|
|
await db.query("UPDATE generated_image_jobs SET lease_until=NOW()-interval '1 second' WHERE id=$1", [unknown.jobId]);
|
|
await service().tick(); assert.equal((await jobs.get(unknown.jobId,101,'clinical_assistant')).outcome, 'unknown');
|
|
assert.equal(paid, before + 1);
|
|
const fenced = await db.query("UPDATE generated_image_jobs SET stage='storing' WHERE id=$1 AND lease_token=$2 AND stage='generating' RETURNING id", [old.id,old.lease_token]); assert.equal(fenced.rows.length, 0);
|
|
const storing = await jobs.enqueue(101,'clinical_assistant',{ prompt: 'Storage crash' },'storage');
|
|
await createImageService({ db, generate, storage: { ...storage, put: async () => { throw Error('synthetic unavailable'); } } }).tick();
|
|
const staged = await db.get('SELECT stage,staged_bytes FROM generated_image_jobs WHERE id=$1', [storing.jobId]);
|
|
assert.equal(staged.stage, 'storing'); assert.notDeepEqual(staged.staged_bytes, png);
|
|
assert.deepEqual(require('../src/utils/crypto').decryptBuffer(staged.staged_bytes), png);
|
|
await db.query("UPDATE generated_image_jobs SET lease_until=NOW()-interval '1 second' WHERE id=$1",[storing.jobId]);
|
|
await service().tick(); assert.equal((await jobs.get(storing.jobId,101,'clinical_assistant')).status, 'done'); assert.equal(paid,before+2);
|
|
const first = await jobs.enqueue(101,'clinical_assistant',{ prompt:'lock one' },'lock-one');
|
|
const second = await jobs.enqueue(102,'clinical_assistant',{ prompt:'lock two' },'lock-two');
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN'); await client.query('SELECT id FROM generated_image_jobs WHERE id=$1 FOR UPDATE',[first.jobId]);
|
|
const claim = await service().claim(); assert.equal(claim.id, second.jobId);
|
|
await client.query('COMMIT');
|
|
} finally { client.release(); }
|
|
await db.query("UPDATE generated_image_jobs SET stage='interrupted' WHERE id=ANY($1::uuid[])", [[first.jobId,second.jobId]]);
|
|
});
|
|
test('budget exact UTF16 assembly, malformed input and missing storage prevent paid calls; provider timeout is explicit unknown', async () => {
|
|
const jobs = service(); const before = paid;
|
|
await db.query("INSERT INTO app_settings(key,value) VALUES('clinical_assistant.image_behavior','X'),('clinical_assistant.image_budget','1000')");
|
|
const base = await jobs.snapshot('clinical_assistant',{ prompt:'x',layout:'square' });
|
|
const prompt = '😀'.repeat(Math.floor((1001-base.rendered.length)/2)) + ('x'.repeat((1001-base.rendered.length)%2));
|
|
const exact = await jobs.snapshot('clinical_assistant',{prompt,layout:'square'}); assert.equal(exact.rendered.length, 1000);
|
|
await assert.rejects(jobs.enqueue(101,'clinical_assistant',{prompt:prompt+'x',layout:'square'},'large'), e => e.statusCode === 413);
|
|
for (const input of [{prompt:''},{prompt:'x',model:'forbidden'},{prompt:'x',layout:'url'},{prompt:'x'.repeat(32001)}]) await assert.rejects(jobs.enqueue(101,'clinical_assistant',input,'bad'));
|
|
const unavailable = createImageService({ db, generate, storage: { ready: async () => { throw Error('synthetic storage outage'); } } });
|
|
await assert.rejects(unavailable.enqueue(101,'clinical_assistant',{prompt:'no call'},'offline'),e => e.statusCode === 503);
|
|
assert.equal(paid,before);
|
|
const unknown = await jobs.enqueue(101,'clinical_assistant',{prompt:'ambiguous timeout'},'timeout');
|
|
await createImageService({ db, storage, generate: async () => { paid++; throw Error('synthetic timeout after possible billing'); } }).tick();
|
|
await jobs.tick(); assert.equal(paid,before+1); assert.equal((await jobs.get(unknown.jobId,101,'clinical_assistant')).outcome,'unknown');
|
|
});
|
|
test('actual authenticated asset/settings and Learning content write routes enforce grants and publication atomically', async () => {
|
|
const express = require('express'); const fs = require('fs'); const vm = require('vm'); const jwt = require('jsonwebtoken');
|
|
const jobs = service();
|
|
await pool.query("ALTER TABLE learning_content ADD title TEXT, ADD slug TEXT, ADD category_id INTEGER, ADD subject TEXT, ADD content_type TEXT, ADD author_id INTEGER, ADD updated_at TIMESTAMPTZ DEFAULT NOW()");
|
|
const convert = sql => { let n=0; return sql.replace(/\?/g,()=>'$'+(++n)); };
|
|
let lockNotice;
|
|
const routeDb = { ...db, pool: { async connect() {
|
|
const client=await pool.connect(); return { release:()=>client.release(), query(sql,params) {
|
|
if(sql.includes('FOR UPDATE') && lockNotice) { lockNotice(); lockNotice=null; }
|
|
return client.query(sql,params);
|
|
} };
|
|
} }, getSetting: async key => (await db.get('SELECT value FROM app_settings WHERE key=$1',[key]))?.value,
|
|
async get(sql,params) { return db.get(convert(sql),params); },
|
|
async all(sql,params) { return db.all(convert(sql),params); },
|
|
async run(sql,params) { const r=await db.query(convert(sql),params);return {lastInsertRowid:r.rows[0]?.id,changes:r.rowCount}; }
|
|
};
|
|
function load(file,mocks) {
|
|
const module={exports:{}};
|
|
vm.runInNewContext(fs.readFileSync(file,'utf8'),{module,Buffer,console:{warn(){},error(){}},process:{env:{JWT_SECRET:'synthetic-signing-only',CLINICAL_ASSISTANT_MCP_WARMUP:'false'}},setTimeout(){},require:n=>{assert.ok(n in mocks,n);return mocks[n];}});
|
|
return module.exports;
|
|
}
|
|
const roles={101:'admin',102:'user',103:'moderator'};
|
|
const auth = load('src/middleware/auth.js',{'jsonwebtoken':jwt,'../db/database':{get:async(sql,params)=>sql.includes('user_sessions')?{id:1,last_activity:new Date()}:roles[params[0]]?{id:params[0],role:roles[params[0]]}:null},'../utils/sessions':{hashToken:()=> 'synthetic-hash'},'../utils/platform':{isMobileClient:()=>false}});
|
|
const imageRoutes=load('src/routes/generatedImages.js',{'express':express,'../middleware/auth':auth,'../utils/generatedImages':{...require('../src/utils/generatedImages'),service:()=>jobs},'../db/database':routeDb});
|
|
const learningRoutes=load('src/routes/learningAdmin.js',{'express':express,'../db/database':routeDb,'../middleware/auth':auth,'../utils/embeddings':{isEmbeddingsAvailable:()=>false},'../utils/generatedImageLinks':links});
|
|
await db.query('CREATE TABLE clinical_assistant_chats(id SERIAL PRIMARY KEY,user_id INTEGER,title TEXT,payload TEXT,created_at TIMESTAMPTZ DEFAULT NOW(),updated_at TIMESTAMPTZ DEFAULT NOW())');
|
|
const learningAI=load('src/routes/learningAI.js',{
|
|
express,multer:require('multer'),axios:{},path:require('path'),'../utils/ai':{},'../utils/imageTool':require('../src/utils/imageTool'),
|
|
'../middleware/auth':auth,'../db/database':routeDb,'../utils/crypto':require('../src/utils/crypto'),'../utils/urlSafety':require('../src/utils/urlSafety'),
|
|
'../utils/policy':{requireFeature:()=>()=>{}},'../utils/generatedImageLinks':links,'../utils/generatedImages':{service:()=>jobs}
|
|
});
|
|
await db.query('CREATE TABLE learning_categories(id SERIAL PRIMARY KEY,name TEXT);CREATE TABLE learning_questions(id SERIAL PRIMARY KEY,content_id INTEGER,sort_order INTEGER)');
|
|
const clinicalRoutes = load('src/routes/clinicalAssistant.js', {
|
|
express, axios: {}, crypto: require('crypto'), '../db/database': routeDb, '../middleware/auth': auth,
|
|
'../utils/ai': {}, '../utils/generatedImages': { ...require('../src/utils/generatedImages'), service: () => jobs },
|
|
'../utils/visionTool': require('../src/utils/visionTool'),
|
|
'../utils/modelVision': { supportsVision: async () => null },
|
|
'../utils/imageTool': require('../src/utils/imageTool'), '../utils/generatedImageLinks': links,
|
|
'../utils/logger': { audit() {}, error() {} }, '../utils/crypto': require('../src/utils/crypto'), '../utils/redis': {},
|
|
'../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) }, '../utils/clinicalMcpClient': {}, '../utils/clinicalRetrieval': {},
|
|
'../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'), '../utils/clinicalConversation': require('../src/utils/clinicalConversation'), '../utils/clinicalAnswer': require('../src/utils/clinicalAnswer')
|
|
});
|
|
// Execute REAL server registrations in order, including the actual blanket adminConfig guard.
|
|
const configRoutes=load('src/routes/adminConfig.js',{
|
|
express,'../db/database':routeDb,'../middleware/auth':auth,'../utils/prompts':{getAllPrompts:()=>[]},
|
|
'../utils/promptCatalog':{},'../utils/promptRevisions':{},'../utils/clinicalConversation':require('../src/utils/clinicalConversation'),
|
|
'../utils/logger':{},'../utils/errors':{},'../utils/ttsProvider':{},'../utils/litellm':{},'../utils/sttProvider':{},'../utils/embeddings':{}
|
|
});
|
|
const app=express();app.use(express.json());
|
|
const composition=fs.readFileSync('server.js','utf8');
|
|
vm.runInNewContext(composition.slice(composition.indexOf('// Routes\n'),composition.indexOf('// User-level preference:')),{
|
|
app,APP_VERSION:'synthetic',process:{env:{}},require(name){
|
|
if(name==='./src/routes/generatedImages') return imageRoutes;
|
|
if(name==='./src/routes/learningAdmin') return learningRoutes;
|
|
if(name==='./src/routes/learningAI') return learningAI;
|
|
if(name==='./src/routes/clinicalAssistant') return clinicalRoutes;
|
|
if(name==='./src/routes/adminConfig') return configRoutes;
|
|
if(name==='./src/middleware/auth') return auth;
|
|
if(name==='./src/db/database') return routeDb;
|
|
if(name==='./src/utils/models') return {activeProvider:'synthetic',getAvailableModelsWithOverrides:async()=>[],getEffectiveDefaultModel:async()=>''};
|
|
if(name==='./src/utils/ai') return {};
|
|
if(name==='./src/utils/generatedImages') return {service:()=>({start(){}})};
|
|
assert.match(name,/^\.\/src\/routes\//);return express.Router(); // no unrelated application modules/services
|
|
}
|
|
});
|
|
const server=app.listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));
|
|
const base='http://127.0.0.1:'+server.address().port;
|
|
const request=(path,owner,method='GET',body)=>fetch(base+path,{method,headers:{'Content-Type':'application/json',...(owner?{Authorization:'Bearer '+jwt.sign({userId:owner},'synthetic-signing-only')}: {})},body:body?JSON.stringify(body):undefined});
|
|
try {
|
|
assert.equal((await request('/api/health')).status,200);
|
|
assert.equal((await request('/api/models')).status,200);
|
|
assert.equal((await request('/api/admin/learning/image/jobs',null,'POST',{prompt:'denied'})).status,401);
|
|
assert.equal((await request('/api/admin/learning/image/jobs',102,'POST',{prompt:'denied'})).status,403);
|
|
assert.equal((await request('/api/admin/config',103)).status,403);
|
|
const moderatorJob=await request('/api/admin/learning/image/jobs',103,'POST',{prompt:'Moderator image',idempotencyKey:'moderator-image'});
|
|
assert.equal(moderatorJob.status,200,await moderatorJob.clone().text());
|
|
const moderatorId=(await moderatorJob.json()).jobId;
|
|
assert.equal((await request('/api/admin/learning/image/jobs/'+moderatorId,103)).status,200);await jobs.tick();
|
|
assert.equal((await request('/api/generated-images/'+image.jobId)).status,401);
|
|
assert.equal((await request('/api/generated-images/'+image.jobId,102)).status,404);
|
|
const bytes=await request('/api/generated-images/'+image.jobId+'?download=1',101);
|
|
assert.equal(bytes.status,200);assert.equal(bytes.headers.get('x-image-owner'),'101');assert.equal(bytes.headers.get('cache-control'),'private, no-store');assert.equal(bytes.headers.get('x-content-type-options'),'nosniff');assert.deepEqual(Buffer.from(await bytes.arrayBuffer()),png);
|
|
const denied=await request('/api/admin/learning/content',101,'POST',{title:'Forbidden',body:'<img src="/api/generated-images/'+image.jobId+'">',published:true});assert.equal(denied.status,403);
|
|
assert.equal((await db.get("SELECT COUNT(*)::int AS n FROM learning_content WHERE title='Forbidden'")).n,0);
|
|
const job=await jobs.enqueue(101,'learning_hub',{prompt:'Attach through actual CMS'},'route-attach');await jobs.tick();
|
|
const draft=await request('/api/admin/learning/content',101,'POST',{title:'Teaching',body:'<p>Exact body.</p><img src="/api/generated-images/'+job.jobId+'">',published:false});assert.equal(draft.status,200);const contentId=(await draft.json()).id;
|
|
assert.equal((await request('/api/generated-images/'+job.jobId,102)).status,404);
|
|
assert.equal((await request('/api/generated-images/'+job.jobId,103)).status,200);
|
|
assert.equal((await request('/api/admin/learning/content/'+contentId,103,'PUT',{published:true})).status,200);
|
|
assert.equal((await request('/api/generated-images/'+job.jobId,102)).status,200);
|
|
assert.equal((await request('/api/admin/learning/content/'+contentId,103,'PUT',{published:false})).status,200);
|
|
assert.equal((await request('/api/generated-images/'+job.jobId,102)).status,404);
|
|
// Concurrent unpublish holds the row while a body-only request reaches its lock.
|
|
await db.query('UPDATE learning_content SET published=true WHERE id=$1',[contentId]);
|
|
const unpublish=await pool.connect();
|
|
try {
|
|
await unpublish.query('BEGIN');await unpublish.query('UPDATE learning_content SET published=false WHERE id=$1',[contentId]);
|
|
let lockReached;const atLock=new Promise(r=>{lockReached=r;});lockNotice=lockReached;
|
|
const update=request('/api/admin/learning/content/'+contentId,103,'PUT',{body:'<p>Concurrent body [3].</p><img src="/api/generated-images/'+job.jobId+'">'});
|
|
await atLock;await unpublish.query('COMMIT');assert.equal((await update).status,200);
|
|
assert.equal((await db.get('SELECT published FROM learning_content WHERE id=$1',[contentId])).published,false,'body edit must not restore stale publication');
|
|
assert.equal((await request('/api/generated-images/'+job.jobId,102)).status,404);
|
|
assert.equal((await db.get('SELECT COUNT(*)::int AS n FROM generated_image_links WHERE content_id=$1',[contentId])).n,1);
|
|
} finally { await unpublish.query('ROLLBACK');unpublish.release(); }
|
|
const markdown='---\nmarp: true\n---\n# Original slide [3]\nDose 5 mg, page 19 [3].\n---\n# Image\n\n';
|
|
const presentation=await request('/api/admin/learning/content',101,'POST',{title:'Presentation',content_type:'presentation',body:markdown,published:false});
|
|
assert.equal(presentation.status,200);const presentationId=(await presentation.json()).id;
|
|
const reopenedPresentation=await request('/api/admin/learning/content/'+presentationId,103);
|
|
assert.equal(reopenedPresentation.status,200);assert.equal((await reopenedPresentation.json()).content.body,markdown);
|
|
const pptx=await request('/api/admin/learning/generate-pptx',103,'POST',{markdown,title:'Synthetic presentation'});
|
|
assert.equal(pptx.status,200,await pptx.clone().text());
|
|
const zip=await require('jszip').loadAsync(Buffer.from(await pptx.arrayBuffer()));
|
|
const media=Object.keys(zip.files).filter(f=>/^ppt\/media\/.+\.png$/.test(f));assert.equal(media.length,1);
|
|
assert.deepEqual(await zip.files[media[0]].async('nodebuffer'),png);
|
|
assert.match(await zip.files['ppt/slides/slide1.xml'].async('string'),/Original slide \[3\]/);
|
|
assert.equal((await request('/api/admin/learning/content',102,'POST',{title:'No permission'})).status,403);
|
|
assert.equal((await request('/api/admin/image-settings/learning_hub',103,'PUT',{model:'synthetic',budget:32000})).status,403);
|
|
assert.equal((await request('/api/admin/image-settings/learning_hub',101,'PUT',{model:'synthetic-own-learning',budget:1500})).status,200);
|
|
assert.equal((await request('/api/admin/image-settings/learning_hub',101,'PUT',{model:'synthetic',budget:32001})).status,400);
|
|
const listing=await (await request('/api/image-jobs/clinical_assistant',101)).text();assert.ok(!listing.includes('prompt_cipher'));assert.ok(!listing.includes('staged_bytes'));
|
|
const body = ' Exact [3, 1].\n| Dose | Page |\n| 5 mg | 19 [3] |\n';
|
|
const payload = { lastAnswer: body, messages: [{ role: 'assistant', content: body, sources: [{ number: 3, page: 19 }], imageJobs: [{ jobId: image.jobId, status: 'forged', imageUrl: 'https://invalid.test' }] }], generatedImage: '/api/generated-images/' + image.jobId };
|
|
const saved = await request('/api/clinical-assistant/chats', 101, 'POST', payload); assert.equal(saved.status, 200); const savedId = (await saved.json()).id;
|
|
const reopened = await (await request('/api/clinical-assistant/chats/' + savedId, 101)).json();
|
|
assert.equal(reopened.chat.payload.lastAnswer, body); assert.equal(reopened.chat.payload.messages[0].content, body);
|
|
assert.deepEqual(reopened.chat.payload.messages[0].sources, [{ number: 3, page: 19 }]);
|
|
assert.deepEqual(reopened.chat.payload.messages[0].imageJobs, [{ jobId: image.jobId }]);
|
|
assert.equal(reopened.chat.payload.generatedImage, undefined, 'offbox image is session-only and never stored');
|
|
assert.match((await db.get('SELECT payload FROM clinical_assistant_chats WHERE id=$1', [savedId])).payload, /^enc1:/);
|
|
assert.equal((await request('/api/clinical-assistant/chats/' + savedId, 102)).status, 404);
|
|
assert.equal((await request('/api/clinical-assistant/chats', 102, 'POST', payload)).status, 403);
|
|
assert.equal((await request('/api/clinical-assistant/chats', 102, 'POST', { messages: [], lastAnswer: '' })).status, 403);
|
|
assert.equal((await request('/api/clinical-assistant/chats', 101, 'POST', { messages: [], generatedImageJobs: [{ jobId: 'bad' }] })).status, 400);
|
|
} finally { await new Promise(r=>server.close(r)); }
|
|
});
|
|
test('both workflows enforce exact 32000 UTF16 assembly and durable snapshots cannot change with admin settings', async () => {
|
|
const jobs = service(); const before = paid;
|
|
for (const workflow of ['clinical_assistant', 'learning_hub']) {
|
|
await db.query("INSERT INTO app_settings(key,value) VALUES($1,'32000') ON CONFLICT(key) DO UPDATE SET value='32000'", [workflow + '.image_budget']);
|
|
const base = await jobs.snapshot(workflow, { prompt: 'x', layout: 'square' });
|
|
const units = 32001 - base.rendered.length;
|
|
const prompt = '😀'.repeat(Math.floor(units / 2)) + 'x'.repeat(units % 2);
|
|
assert.equal((await jobs.snapshot(workflow, { prompt, layout: 'square' })).rendered.length, 32000);
|
|
await assert.rejects(jobs.enqueue(101, workflow, { prompt: prompt + 'x', layout: 'square' }, 'over-32000'), e => e.statusCode === 413);
|
|
const job = await jobs.enqueue(101, workflow, { prompt, layout: 'square' }, 'exact-32000');
|
|
const original = await db.get('SELECT * FROM generated_image_jobs WHERE id=$1', [job.jobId]);
|
|
assert.equal(original.prompt_units, 32000); assert.match(original.prompt_cipher, /^enc1:/);
|
|
for (const [field, value] of [['owner_id', 102], ['model', 'replacement'], ['budget', 1000], ['prompt_cipher', 'enc1:replacement'], ['prompt_revision', original.prompt_revision + 1], ['context_total', 9], ['context_included', 1]]) {
|
|
await assert.rejects(db.query(`UPDATE generated_image_jobs SET ${field}=$2 WHERE id=$1`, [job.jobId, value]), /immutable/);
|
|
}
|
|
await db.query("UPDATE app_settings SET value='1000' WHERE key=$1", [workflow + '.image_budget']);
|
|
let sent;
|
|
await createImageService({ db, storage, generate: async (snapshot, text) => { sent = { snapshot, text }; paid++; return inspect(png); } }).tick();
|
|
assert.equal(sent.snapshot.id, job.jobId); assert.equal(sent.snapshot.budget, 32000); assert.equal(sent.snapshot.model, original.model);
|
|
assert.equal(sent.text, require('../src/utils/crypto').decryptString(original.prompt_cipher));
|
|
}
|
|
assert.equal(paid, before + 2);
|
|
});
|
|
test('concurrent tool replay, owner/workflow identity and preflight failures never create duplicate paid jobs', async () => {
|
|
const jobs = service(); const before = paid; const tool = require('../src/utils/imageTool');
|
|
const opts = { owner: 101, workflow: 'clinical_assistant', body: { idempotencyKey: 'concurrent-tool' }, imageContext:{request:'Concurrent original request',history:[]}, images: jobs };
|
|
const result = prompt => ({ content: 'Exact body [3].', toolCalls: [{ id: 'one', type: 'function', function: { name: 'generate_image', arguments: JSON.stringify({ prompt }) } }] });
|
|
const [a, b] = await Promise.all([tool.dispatch(result('First replay diagram'), opts), tool.dispatch(result('Second replay diagram'), opts)]);
|
|
assert.equal(a.imageJobs[0].jobId, b.imageJobs[0].jobId);
|
|
const other = await jobs.enqueue(102, 'clinical_assistant', { prompt: 'Other owner' }, 'tool:concurrent-tool');
|
|
const learning = await jobs.enqueue(101, 'learning_hub', { prompt: 'Other workflow' }, 'tool:concurrent-tool');
|
|
assert.notEqual(other.jobId, a.imageJobs[0].jobId); assert.notEqual(learning.jobId, a.imageJobs[0].jobId);
|
|
await jobs.tick(); await jobs.tick(); await jobs.tick(); await jobs.tick(); assert.equal(paid, before + 3);
|
|
const noMigration = createImageService({ db: { query: async () => { throw Error('synthetic missing migration'); } }, storage, generate });
|
|
await assert.rejects(noMigration.ready(), e => e.statusCode === 503);
|
|
const noEncryption = createImageService({ db, storage, generate, encryption: { hasKey: () => false } });
|
|
await assert.rejects(noEncryption.enqueue(101, 'clinical_assistant', { prompt: 'No encryption' }, 'no-key'), e => e.statusCode === 503);
|
|
const noGateway = createImageService({ db, storage, env: {}, encryption: require('../src/utils/crypto') });
|
|
await assert.rejects(noGateway.ready(), e => e.statusCode === 503);
|
|
assert.equal(paid, before + 3);
|
|
});
|
|
test('worker stop during preflight or claim never starts a new paid request; queued work resumes safely', async () => {
|
|
for (const pauseAt of ['preflight', 'claim']) {
|
|
const jobs = service(); const before = paid;
|
|
const job = await jobs.enqueue(101, 'clinical_assistant', { prompt: 'Stop before payment' }, 'stop-' + pauseAt);
|
|
let reached, release;
|
|
const paused = new Promise(resolve => { reached = resolve; });
|
|
const gate = new Promise(resolve => { release = resolve; });
|
|
const worker = createImageService({ generate,
|
|
storage: { ...storage, ready: async () => { await storage.ready(); if (pauseAt === 'preflight') { reached(); await gate; } } },
|
|
db: { ...db, pool: { async connect() {
|
|
const client = await pool.connect();
|
|
return { release: () => client.release(), async query(...args) {
|
|
const result = await client.query(...args);
|
|
if (pauseAt === 'claim' && args[0] === 'COMMIT') { reached(); await gate; }
|
|
return result;
|
|
} };
|
|
} } }
|
|
});
|
|
worker.start(); await paused;
|
|
const stopped = worker.stop(); release(); await stopped;
|
|
assert.equal(paid, before, pauseAt); assert.equal((await jobs.get(job.jobId, 101, 'clinical_assistant')).status, 'pending');
|
|
await service().tick(); assert.equal(paid, before + 1); assert.equal((await jobs.get(job.jobId, 101, 'clinical_assistant')).status, 'done');
|
|
}
|
|
});
|
|
test('exact IMAGE HTTP input binds original request and contiguous whole recent turns; snapshots, UTF16 metadata and replay remain honest', async () => {
|
|
const http = require('node:http'); const captured = [];
|
|
const server = http.createServer(async (req,res) => {
|
|
const chunks=[]; for await (const c of req) chunks.push(c);
|
|
captured.push({path:req.url,body:JSON.parse(Buffer.concat(chunks))});
|
|
res.writeHead(200,{'Content-Type':'application/json'}); res.end(JSON.stringify({data:[{b64_json:png.toString('base64')}]}));
|
|
});
|
|
server.listen(0,'127.0.0.1'); await new Promise(r=>server.once('listening',r));
|
|
const old = process.env.LITELLM_API_BASE; process.env.LITELLM_API_BASE='http://127.0.0.1:'+server.address().port+'/v1';
|
|
try {
|
|
await db.query("UPDATE app_settings SET value='12000' WHERE key='clinical_assistant.image_budget'");
|
|
const jobs=createImageService({db,storage});
|
|
const context={request:' ORIGINAL request: draw the latest corrected dose 😀 [3].\n',history:[
|
|
{role:'user',content:'Old tiny turn must not jump a gap.'},
|
|
{role:'assistant',content:'too large boundary '+ '😀'.repeat(7000)},
|
|
{role:'user',content:'Recent correction '+ '😀'.repeat(1600)},
|
|
{role:'assistant',content:' Exact table [3, 1].\n| Dose | Page |\n| 5 mg | 19 [3] |\n'}]};
|
|
const original=JSON.stringify(context); const input={prompt:'MODEL DESCRIPTION ONLY',layout:'portrait'};
|
|
const tool=require('../src/utils/imageTool');
|
|
const opts={owner:101,workflow:'clinical_assistant',body:{idempotencyKey:'exact-image-input'},imageContext:context,images:jobs};
|
|
const ai={content:'Unchanged [3, 1].',toolCalls:[{id:'ctx',type:'function',function:{name:'generate_image',arguments:JSON.stringify(input)}}]};
|
|
const job=(await tool.dispatch(ai,opts)).imageJobs[0];
|
|
await jobs.tick(); assert.equal(captured.length,1);
|
|
const sent=captured[0].body.prompt;
|
|
assert.ok(sent.includes(context.request),'ORIGINAL request must reach IMAGE provider');
|
|
assert.ok(sent.includes(input.prompt));
|
|
assert.ok(sent.includes(context.history[2].content)); assert.ok(sent.includes(context.history[3].content));
|
|
assert.ok(sent.indexOf(context.history[2].content)<sent.indexOf(context.history[3].content));
|
|
assert.ok(!sent.includes(context.history[0].content)); assert.ok(!sent.includes(context.history[1].content));
|
|
assert.match(sent,/image only/i); assert.match(sent,/citations, reference numbers, footnotes, bibliography, or source lists/i);
|
|
assert.equal(captured[0].path,'/v1/images/generations'); assert.equal(captured[0].body.model,'synthetic-clinical-image');
|
|
assert.deepEqual(job.context,{includedTurns:2,totalTurns:4,used:sent.length,limit:12000,unit:'UTF-16 code units'});
|
|
assert.deepEqual((await jobs.get(job.jobId,101,'clinical_assistant')).context,job.context);
|
|
const row=await db.get('SELECT * FROM generated_image_jobs WHERE id=$1',[job.jobId]);
|
|
assert.equal(require('../src/utils/crypto').decryptString(row.prompt_cipher),sent); assert.equal(row.prompt_units,sent.length);
|
|
assert.ok(!row.prompt_cipher.includes(context.request)); assert.equal(JSON.stringify(context),original);
|
|
const replay=await tool.dispatch({...ai,toolCalls:[{...ai.toolCalls[0],function:{name:'generate_image',arguments:'{"prompt":"changed model wording"}'}}]},opts);
|
|
assert.equal(replay.imageJobs[0].jobId,job.jobId);
|
|
await assert.rejects(tool.dispatch(ai,{...opts,imageContext:{...context,history:context.history.concat({role:'user',content:'new'})}}),e=>e.statusCode===409);
|
|
for (const workflow of ['clinical_assistant','learning_hub']) {
|
|
await db.query("UPDATE app_settings SET value='32000' WHERE key=$1",[workflow+'.image_budget']);
|
|
const base=await jobs.snapshot(workflow,{prompt:'x'});
|
|
const text='😀'.repeat(Math.floor((32001-base.rendered.length)/2))+'x'.repeat((32001-base.rendered.length)%2);
|
|
const exact=await jobs.enqueue(101,workflow,{prompt:text},'http-exact-'+workflow);
|
|
await jobs.tick(); assert.equal(captured.at(-1).body.prompt.length,32000);
|
|
assert.match(captured.at(-1).body.prompt,/image only/i); assert.equal(exact.context.totalTurns,0);
|
|
await assert.rejects(jobs.enqueue(101,workflow,{prompt:text+'x'},'http-over-'+workflow),e=>e.statusCode===413);
|
|
}
|
|
assert.equal(captured.length,3,'mandatory overflow never calls IMAGE provider');
|
|
} finally { if(old===undefined) delete process.env.LITELLM_API_BASE; else process.env.LITELLM_API_BASE=old; await new Promise(r=>server.close(r)); }
|
|
});
|
|
test('expired PAID lease becomes explicit unknown even while storage and gateway readiness fail', async () => {
|
|
const jobs=service(),before=paid;
|
|
const job=await jobs.enqueue(101,'clinical_assistant',{prompt:'Crash plus outage'},'combined-outage');
|
|
const claim=await jobs.claim();assert.equal(claim.id,job.jobId);
|
|
await db.query("UPDATE generated_image_jobs SET lease_until=NOW()-interval '1 second' WHERE id=$1",[job.jobId]);
|
|
const down=createImageService({db,env:{},storage:{ready:async()=>{throw Error('synthetic outage');}}});
|
|
await down.tick().catch(()=>{});
|
|
assert.equal((await jobs.get(job.jobId,101,'clinical_assistant')).outcome,'unknown');assert.equal(paid,before);
|
|
await jobs.tick();assert.equal(paid,before);
|
|
});
|
|
test('partial schema without links/asset-read relation fails readiness and queued payment against real PG', async () => {
|
|
const jobs=service(),before=paid;
|
|
const job=await jobs.enqueue(101,'clinical_assistant',{prompt:'Do not pay with missing links'},'partial-schema');
|
|
await db.query('ALTER TABLE generated_image_links RENAME TO unavailable_links');
|
|
try {
|
|
await assert.rejects(jobs.ready(),e=>e.statusCode===503);
|
|
await assert.rejects(jobs.tick(),e=>e.statusCode===503);
|
|
await assert.rejects(jobs.enqueue(101,'clinical_assistant',{prompt:'blocked too'},'partial-new'),e=>e.statusCode===503);
|
|
assert.equal(paid,before);
|
|
} finally { await db.query('ALTER TABLE unavailable_links RENAME TO generated_image_links');await db.query("UPDATE generated_image_jobs SET stage='interrupted' WHERE id=$1",[job.jobId]); }
|
|
});
|
|
test('whole-turn context exactly fills the cap or is omitted in full; mandatory original request overflow never pays', async () => {
|
|
const jobs=service(),before=paid;
|
|
for(const workflow of ['clinical_assistant','learning_hub']) {
|
|
await db.query("UPDATE app_settings SET value='32000' WHERE key=$1",[workflow+'.image_budget']);
|
|
const input={prompt:'Image description',layout:'square'},context={request:' Original image request 😀\n',history:[{role:'user',content:'x'}]};
|
|
const base=await jobs.snapshot(workflow,input,context);const remaining=32001-base.rendered.length;
|
|
context.history[0].content='😀'.repeat(Math.floor(remaining/2))+'x'.repeat(remaining%2);
|
|
const original=JSON.stringify(context),exact=await jobs.snapshot(workflow,input,context);
|
|
assert.equal(exact.rendered.length,32000);assert.equal(exact.included,1);assert.ok(exact.rendered.includes(context.history[0].content));
|
|
context.history[0].content+='x';const over=await jobs.snapshot(workflow,input,context);
|
|
assert.equal(over.included,0);assert.equal(over.total,1);assert.ok(!over.rendered.includes(context.history[0].content));
|
|
assert.ok(over.rendered.includes(context.request));assert.ok(over.rendered.endsWith(require('../src/utils/generatedImages').IMAGE_OUTPUT_RULE));
|
|
context.history[0].content=context.history[0].content.slice(0,-1);assert.equal(JSON.stringify(context),original);
|
|
await assert.rejects(jobs.enqueue(101,workflow,input,'mandatory-original-overflow',false,{request:'😀'.repeat(16000),history:[]}),e=>e.statusCode===413);
|
|
}
|
|
assert.equal(paid,before);
|
|
});
|