Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Eleven routers are mounted on '/api' and called router.use(authMiddleware) with no path. Mounted that way, the gate applies to every /api request that reaches the router — including routes belonging to routers mounted further down server.js. extensions.js did it from line 295; the assistant is mounted at 305. So a signed-out request to /api/clinical-assistant/status was refused ten lines before the preview middleware could look at it, whatever the admin setting said. server.js line 250 already warned about this shape. Each gate now names its own prefix, so a router protects its own routes and nothing else. Verified afterwards that every namespace which must stay shut still answers 401 signed out: extensions, encounters, memories, notes, diagrams, generated images, image jobs, documents, audio backups, ED encounters, don't-miss, patient education, billing, well visit, admin, transcribe and the rest. Two of these routers were gating routes nobody realised they were gating. Second defect in the same path: authMiddleware only ever looks for a token, so calling it unconditionally after the preview identity had been assigned rejected exactly the requests preview exists to serve. Only that identity may skip it; authMiddleware stays strict everywhere else. Preview now answers with a real cited answer, and stays as narrow as it was designed to be — four allow-listed paths, no identity, nothing ownable. A test now walks every /api router and fails on a blanket gate, which is how the last six were found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
594 lines
39 KiB
JavaScript
594 lines
39 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 crypto = require('node:crypto');
|
|
const http = require('node:http');
|
|
const { createRequire } = require('node:module');
|
|
const express = require('express');
|
|
const jwt = require('jsonwebtoken');
|
|
const bcrypt = require('bcryptjs');
|
|
const root = path.join(__dirname, '..');
|
|
const secret = 'synthetic-policy-test-secret';
|
|
const password = bcrypt.hashSync('synthetic-password', 4);
|
|
|
|
// Execute actual local modules, including server.js and its real routers. Only
|
|
// infrastructure boundaries (DB, IdP, providers, logs, timers, listener) are fake.
|
|
function fixture(envOverrides = {}) {
|
|
const state = {
|
|
settings: {
|
|
'models.custom': JSON.stringify([{ id: 'allowed', name: 'Allowed' }, { id: 'other', name: 'Other' }]),
|
|
'models.disabled': '[]', 'models.default': 'allowed',
|
|
'oidc.enabled': 'true', 'oidc.disable_local_auth': 'false',
|
|
'oidc.issuer': 'https://idp.example', 'oidc.client_id': 'synthetic-client',
|
|
'feature.read_aloud': 'true', 'feature.nextcloud': 'true', 'feature.memories': 'true',
|
|
'tts.model': 'local-kitten-tts', 'tts.voice': 'Luna', 'stt.model': 'synthetic-stt'
|
|
},
|
|
user: { id: 7, email: 'synthetic@example.test', name: 'Synthetic', role: 'admin', password, email_verified: true, disabled: false },
|
|
writes: [], queries: [], requests: [], grants: [], logs: [], authorizations: new Map(), usedCodes: new Set(),
|
|
claims: { sub: 'subject-7', email: 'synthetic@example.test', email_verified: true, name: 'Synthetic' }
|
|
};
|
|
const env = { JWT_SECRET: secret, DATA_ENCRYPTION_KEY: 'a'.repeat(64), APP_URL: 'https://app.example', AI_PROVIDER: 'litellm', LITELLM_API_BASE: 'https://gateway.example/v1', LITELLM_API_KEY: 'synthetic', ...envOverrides };
|
|
const db = {
|
|
async getSetting(key) { if (state.settingsError) throw new Error('synthetic settings failure'); return state.settings[key] ?? null; },
|
|
async setSetting(key, value) { state.settings[key] = value; },
|
|
async get(sql, params) {
|
|
state.queries.push(sql);
|
|
if (sql.includes('COUNT(*)') && sql.includes('FROM users')) return { count: 1 };
|
|
if (sql.includes('FROM users')) return state.user ? { ...state.user } : null;
|
|
if (sql.includes('FROM user_sessions')) return { id: 'session-7', last_activity: new Date() };
|
|
if (sql.includes('COUNT(*)') && sql.includes('user_memories')) return { cnt: 1 };
|
|
throw new Error('Unexpected DB get: ' + sql);
|
|
},
|
|
async all(sql) {
|
|
state.queries.push(sql);
|
|
if (sql.includes('user_memories')) return [{ id: 1, category: 'physical_exam', name: 'Synthetic template', content: 'MEMORY_SENTINEL' }];
|
|
return [];
|
|
},
|
|
async run(sql, params) {
|
|
state.writes.push({ sql, params });
|
|
if (sql.includes('INSERT INTO user_sessions') && state.sessionError) throw new Error('synthetic session failure');
|
|
if (sql.includes('INSERT INTO users')) state.user = { id: 8, email: params[0], password: params[1], name: params[2], role: params[3], email_verified: true };
|
|
return { lastInsertRowid: 8, changes: 1 };
|
|
}
|
|
};
|
|
const logger = new Proxy({}, { get: () => (...args) => state.logs.push(args) });
|
|
const fakeOIDC = {
|
|
discovery: async () => ({}),
|
|
randomPKCECodeVerifier: () => crypto.randomBytes(32).toString('base64url'),
|
|
calculatePKCECodeChallenge: async verifier => crypto.createHash('sha256').update(verifier).digest('base64url'),
|
|
buildAuthorizationUrl(config, params) {
|
|
state.authorizations.set(params.state, params);
|
|
return new URL('https://idp.example/authorize?' + new URLSearchParams(params));
|
|
},
|
|
async authorizationCodeGrant(config, url, options) {
|
|
state.grants.push(options);
|
|
const auth = state.authorizations.get(options.expectedState);
|
|
assert.ok(auth, 'grant belongs to an initiated transaction');
|
|
assert.equal(options.expectedNonce, auth.nonce);
|
|
assert.equal(await fakeOIDC.calculatePKCECodeChallenge(options.pkceCodeVerifier), auth.code_challenge);
|
|
const code = url.searchParams.get('code');
|
|
if (!code || state.usedCodes.has(code)) throw new Error('code already consumed');
|
|
state.usedCodes.add(code);
|
|
if (state.idpError) throw new Error('sensitive-verifier-' + options.pkceCodeVerifier);
|
|
return { claims: () => state.claims };
|
|
}
|
|
};
|
|
class FakeOpenAI {
|
|
constructor() {
|
|
this.chat = { completions: { create: async payload => {
|
|
state.requests.push(payload);
|
|
if (state.providerError) throw new Error('synthetic provider failure');
|
|
if (payload.stream) return (async function* () { yield { choices: [{ delta: { content: 'generated' }, finish_reason: 'stop' }] }; })();
|
|
return { choices: [{ message: { content: state.aiContent || 'generated' }, finish_reason: 'stop' }] };
|
|
} } };
|
|
}
|
|
}
|
|
async function providerFetch(url, options) {
|
|
state.requests.push({ url: String(url), options });
|
|
assert.ok(String(url).startsWith('https://gateway.example/') || String(url).startsWith('https://api.pwnedpasswords.com/'), 'Unexpected network URL');
|
|
return {
|
|
ok: !state.httpError, status: state.httpError || 200,
|
|
json: async () => state.httpData || { text: 'synthetic transcript' }, text: async () => '',
|
|
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
|
headers: { get: () => 'audio/wav' }
|
|
};
|
|
}
|
|
const cache = new Map();
|
|
function load(file) {
|
|
const filename = path.resolve(root, file);
|
|
const rel = path.relative(root, filename).replaceAll(path.sep, '/');
|
|
if (rel === 'src/db/database.js') return db;
|
|
if (rel === 'src/utils/logger.js') return logger;
|
|
if (rel === 'src/utils/notify.js') return new Proxy({}, { get: () => async () => {} });
|
|
if (rel === 'src/middleware/logging.js') return (req, res, next) => next();
|
|
if (rel === 'src/utils/metrics.js') return { metricsMiddleware: (req, res, next) => next(), metricsHandler: (req, res) => res.end() };
|
|
if (cache.has(filename)) return cache.get(filename).exports;
|
|
const module = { exports: {} };
|
|
cache.set(filename, module);
|
|
const nativeRequire = createRequire(filename);
|
|
function localRequire(name) {
|
|
if (name.startsWith('.')) {
|
|
const resolved = nativeRequire.resolve(name);
|
|
if (resolved.startsWith(root + path.sep) && resolved.endsWith('.js')) return load(resolved);
|
|
return nativeRequire(name);
|
|
}
|
|
if (name === 'openid-client') return fakeOIDC;
|
|
if (name === 'openai') return { OpenAI: FakeOpenAI };
|
|
if (name === 'dns') return { promises: { lookup: async () => [{ address: '8.8.8.8' }] } };
|
|
if (name === 'axios') {
|
|
const dav = async options => {
|
|
if (!state.allowDav || !options.url.startsWith('https://cloud.example/')) throw new Error('Unexpected axios request');
|
|
state.requests.push({ dav: options });
|
|
return { data: '<d:multistatus></d:multistatus>', headers: { 'content-type': 'text/plain' }, status: 200 };
|
|
};
|
|
return Object.assign(dav, { get: (url, options) => dav({ url, ...options }) });
|
|
}
|
|
if (name === 'dotenv') return { config() {} };
|
|
if (name === 'http' && rel === 'server.js') return { createServer(app) { state.app = app; return { listen() {}, close() {} }; } };
|
|
return nativeRequire(name);
|
|
}
|
|
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), {
|
|
module, exports: module.exports, require: localRequire, __dirname: path.dirname(filename), __filename: filename,
|
|
process: { env, on() {}, exit(code) { throw new Error('Unexpected process exit ' + code); } },
|
|
console: logger, Buffer, URL, URLSearchParams, TextEncoder, TextDecoder, AbortController, File, Blob, FormData,
|
|
fetch: providerFetch, setTimeout: () => ({ unref() {} }), clearTimeout() {}, setInterval: () => ({ unref() {} }), clearInterval() {}
|
|
}, { filename });
|
|
return module.exports;
|
|
}
|
|
async function serve(t, serverModule = false) {
|
|
if (serverModule) load('server.js');
|
|
else {
|
|
state.app = express();
|
|
state.app.use(express.json());
|
|
state.app.use(require('cookie-parser')());
|
|
state.app.use('/api/auth', load('src/routes/oidc.js'));
|
|
state.app.use('/api/auth', load('src/routes/auth.js'));
|
|
}
|
|
const server = http.createServer(state.app);
|
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
|
|
const base = 'http://127.0.0.1:' + server.address().port;
|
|
return async function request(route, { method = 'GET', body, cookie, role, authenticated = false } = {}) {
|
|
if (role) state.user.role = role;
|
|
const headers = {};
|
|
if (cookie) headers.cookie = cookie;
|
|
if (authenticated || role) headers.authorization = 'Bearer ' + jwt.sign({ userId: 7 }, secret);
|
|
if (body !== undefined && !(body instanceof FormData)) headers['content-type'] = 'application/json';
|
|
const response = await fetch(base + route, { method, headers, body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body), redirect: 'manual' });
|
|
const text = await response.text();
|
|
let data; try { data = JSON.parse(text); } catch (_) { data = text; }
|
|
return { status: response.status, headers: response.headers, data };
|
|
};
|
|
}
|
|
return { state, db, load, serve };
|
|
}
|
|
|
|
async function initiate(request) {
|
|
const response = await request('/api/auth/oidc');
|
|
assert.equal(response.status, 302);
|
|
const url = new URL(response.headers.get('location'));
|
|
const cookie = response.headers.getSetCookie().find(c => c.startsWith('ped_oidc='));
|
|
assert.match(cookie, /HttpOnly/); assert.match(cookie, /Secure/); assert.match(cookie, /SameSite=Lax/); assert.match(cookie, /Path=\/api\/auth\/oidc/);
|
|
assert.match(url.searchParams.get('state'), /^[a-f0-9]{48}$/);
|
|
assert.equal(url.searchParams.has('code_verifier'), false);
|
|
return { state: url.searchParams.get('state'), cookie: cookie.split(';')[0] };
|
|
}
|
|
function authCookies(response) { return response.headers.getSetCookie().filter(c => c.startsWith('ped_auth=')); }
|
|
function assertCleared(response) { assert.match(response.headers.getSetCookie().find(c => c.startsWith('ped_oidc=')), /Expires=Thu, 01 Jan 1970/); }
|
|
function signTransaction(payload) {
|
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
return 'ped_oidc=' + body + '.' + crypto.createHmac('sha256', secret).update(body).digest('base64url');
|
|
}
|
|
|
|
test('OIDC is browser-bound with opaque state; successful PKCE/nonce exchange creates session before cookie', async t => {
|
|
const f = fixture(); const request = await f.serve(t);
|
|
const a = await initiate(request); const b = await initiate(request);
|
|
const wrong = await request('/api/auth/oidc/callback?state=' + a.state + '&code=one', { cookie: b.cookie });
|
|
assert.match(wrong.headers.get('location'), /invalid_state/); assertCleared(wrong);
|
|
assert.equal(f.state.grants.length, 0);
|
|
const good = await request('/api/auth/oidc/callback?state=' + a.state + '&code=one', { cookie: a.cookie });
|
|
assert.match(good.headers.get('location'), /sso=ok/); assertCleared(good); assert.equal(authCookies(good).length, 1);
|
|
assert.ok(f.state.writes.some(w => w.sql.includes('INSERT INTO user_sessions')));
|
|
assert.ok(f.state.writes.some(w => w.sql.includes('UPDATE users SET oidc_sub')));
|
|
const replay = await request('/api/auth/oidc/callback?state=' + a.state + '&code=one');
|
|
assert.match(replay.headers.get('location'), /invalid_state/); assertCleared(replay);
|
|
const copiedReplay = await request('/api/auth/oidc/callback?state=' + a.state + '&code=one', { cookie: a.cookie });
|
|
assert.match(copiedReplay.headers.get('location'), /sso_failed/); assert.equal(authCookies(copiedReplay).length, 0);
|
|
for (const grant of f.state.grants) assert.ok(!JSON.stringify(f.state.logs).includes(grant.pkceCodeVerifier));
|
|
});
|
|
|
|
test('OIDC rejects expired, malformed, forged and missing transactions without reaching IdP', async t => {
|
|
const f = fixture(); const request = await f.serve(t); const a = await initiate(request);
|
|
const payload = { s: a.state, n: 'b'.repeat(48), v: 'v'.repeat(43), expires: Date.now() + 60000 };
|
|
for (const cookie of [undefined, 'ped_oidc=bad.x', 'ped_oidc=' + 'a'.repeat(2100), a.cookie + 'x',
|
|
signTransaction({ ...payload, expires: Date.now() - 1 }), signTransaction({ ...payload, expires: Date.now() + 600000 }),
|
|
signTransaction({ ...payload, v: 'short' }), signTransaction({ ...payload, n: [] })]) {
|
|
const response = await request('/api/auth/oidc/callback?state=' + a.state + '&code=x', { cookie });
|
|
assert.match(response.headers.get('location'), /invalid_state/); assertCleared(response); assert.equal(authCookies(response).length, 0);
|
|
}
|
|
for (const query of ['state=bad', 'state=' + a.state + '&state=' + a.state, 'state[x]=bad']) {
|
|
const response = await request('/api/auth/oidc/callback?' + query, { cookie: a.cookie });
|
|
assert.match(response.headers.get('location'), /invalid_state/);
|
|
}
|
|
assert.equal(f.state.grants.length, 0);
|
|
});
|
|
|
|
test('OIDC refuses unsafe linking, disabled/mismatched identities, and session failure; keeps linked/new flows', async t => {
|
|
const f = fixture(); const request = await f.serve(t);
|
|
const baseline = { ...f.state.user };
|
|
for (const scenario of [
|
|
{ user: { email_verified: false }, error: 'account_link_required' },
|
|
{ user: { disabled: true }, error: 'disabled' },
|
|
{ user: { oidc_sub: 'other-sub' }, error: 'sub_mismatch' },
|
|
{ claims: { email_verified: false }, error: 'email_unverified' },
|
|
{ sessionError: true, error: 'sso_failed' },
|
|
{ idpError: true, error: 'sso_failed' },
|
|
{ user: { oidc_sub: 'subject-7', email_verified: false }, success: true },
|
|
{ newUser: true, success: true }
|
|
]) {
|
|
f.state.user = scenario.newUser ? null : { ...baseline, ...scenario.user };
|
|
f.state.claims.email_verified = scenario.claims ? false : true;
|
|
f.state.sessionError = !!scenario.sessionError; f.state.idpError = !!scenario.idpError; f.state.writes.length = 0;
|
|
const a = await initiate(request);
|
|
const response = await request('/api/auth/oidc/callback?state=' + a.state + '&code=' + a.state, { cookie: a.cookie });
|
|
assertCleared(response);
|
|
assert.match(response.headers.get('location'), new RegExp(scenario.success ? 'sso=ok' : scenario.error));
|
|
assert.equal(authCookies(response).length, scenario.success ? 1 : 0);
|
|
if (!scenario.success && !scenario.sessionError) assert.equal(f.state.writes.length, 0, 'no account mutation before identity/disabled checks');
|
|
for (const grant of f.state.grants) assert.ok(!JSON.stringify(f.state.logs).includes(grant.pkceCodeVerifier));
|
|
}
|
|
});
|
|
|
|
test('active SSO-only policy denies local login/registration/credential creation, but disabled OIDC cannot lock out local auth', async t => {
|
|
const f = fixture(); const request = await f.serve(t, true);
|
|
f.state.settings['oidc.disable_local_auth'] = 'true';
|
|
for (const route of ['/api/auth/login', '/api/auth/register', '/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/change-password', '/api/auth/setup-2fa', '/api/auth/verify-2fa', '/api/auth/2fa/backup-codes', '/api/admin/users/7/reset-password']) {
|
|
const response = await request(route, { method: 'POST', body: {}, authenticated: true });
|
|
assert.equal(response.status, 403, route); assert.equal(response.data.code, 'sso_only'); assert.equal(authCookies(response).length, 0);
|
|
}
|
|
assert.equal(f.state.writes.length, 0);
|
|
assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, true);
|
|
assert.equal((await request('/api/auth/registration-status')).data.registrationEnabled, false);
|
|
assert.equal((await request('/api/auth/me', { authenticated: true })).data.user.canLocalAuth, false);
|
|
f.state.settings['oidc.enabled'] = 'false';
|
|
assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, false);
|
|
const local = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } });
|
|
assert.equal(local.status, 200); assert.equal(authCookies(local).length, 1);
|
|
f.state.settingsError = true;
|
|
const unavailable = await request('/api/auth/login', { method: 'POST', body: {} });
|
|
assert.equal(unavailable.status, 503); assert.equal(authCookies(unavailable).length, 0);
|
|
});
|
|
|
|
test('local login and auto-verified registration never issue success/cookie on session insert failure', async t => {
|
|
const f = fixture(); const request = await f.serve(t); f.state.sessionError = true;
|
|
const login = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } });
|
|
assert.equal(login.status, 500); assert.equal(authCookies(login).length, 0); assert.equal(login.data.success, undefined);
|
|
f.state.user = null;
|
|
const registration = await request('/api/auth/register', { method: 'POST', body: { email: 'new@example.test', password: 'synthetic-password', name: 'Synthetic' } });
|
|
assert.equal(registration.status, 500); assert.equal(authCookies(registration).length, 0); assert.equal(registration.data.success, undefined);
|
|
assert.equal(f.state.writes.filter(w => w.sql.includes('INSERT INTO user_sessions')).length, 2);
|
|
});
|
|
|
|
test('final model allowlist is enforced for streaming/nonstream, invalid settings/outage, defaults and fallback', async () => {
|
|
const f = fixture({ LITELLM_DEFAULT_MODEL: 'not-enabled', LITELLM_FALLBACK_MODEL: 'not-enabled' });
|
|
const ai = f.load('src/utils/ai.js'); const models = f.load('src/utils/models.js');
|
|
for (const call of [options => ai.callAI([], options), options => ai.callAIStream([], options, () => {})]) {
|
|
await call({ model: 'allowed' });
|
|
f.state.settings['models.disabled'] = '["other"]';
|
|
await assert.rejects(call({ model: 'other' }), /Model not permitted/);
|
|
f.state.settings['models.custom'] = '[]';
|
|
await assert.rejects(call({ model: 'allowed' }), /Model not permitted/);
|
|
await assert.rejects(call({}), /Model not permitted/);
|
|
for (const invalid of ['', '{', '{}', 'null', '[null]', '[{"id":""}]']) {
|
|
f.state.settings['models.custom'] = invalid;
|
|
await assert.rejects(call({ model: 'allowed' }));
|
|
}
|
|
f.state.settings['models.custom'] = '[{"id":"allowed"}]';
|
|
for (const invalid of ['', '{}', 'null', '[null]']) {
|
|
f.state.settings['models.disabled'] = invalid;
|
|
await assert.rejects(call({ model: 'allowed' }));
|
|
}
|
|
f.state.settings['models.disabled'] = '[]';
|
|
await call({ model: 'allowed' }); // warm path must not retain grants during outage
|
|
f.state.settingsError = true;
|
|
await assert.rejects(call({ model: 'allowed' })); await assert.rejects(call({}));
|
|
f.state.settingsError = false;
|
|
}
|
|
delete f.state.settings['models.custom']; delete f.state.settings['models.disabled'];
|
|
assert.equal((await models.getAllowedModelIds(f.db)).size, 0);
|
|
await assert.rejects(ai.callAI([], {}), /Model not permitted/);
|
|
f.state.settings['models.custom'] = '[{"id":"allowed"}]';
|
|
f.state.settings['models.default'] = 'removed';
|
|
assert.equal(await models.getEffectiveDefaultModel(f.db), 'allowed');
|
|
await ai.callAI([], {}); assert.equal(f.state.requests.at(-1).model, 'allowed');
|
|
f.state.settings['ai.allow_model_fallback'] = 'true'; f.state.providerError = true;
|
|
const start = f.state.requests.length;
|
|
await assert.rejects(ai.callAI([], { model: 'allowed' }));
|
|
assert.equal(f.state.requests.length - start, 1, 'disabled fallback never reaches provider');
|
|
f.state.providerError = false;
|
|
await ai.callAI([], { model: 'admin-probe', skipAllowlistCheck: true });
|
|
assert.equal(f.state.requests.at(-1).model, 'admin-probe');
|
|
});
|
|
|
|
test('merged static/custom roster filters disabled custom models too', async () => {
|
|
const f = fixture({ AI_PROVIDER: 'openrouter' }); const models = f.load('src/utils/models.js');
|
|
f.state.settings['models.disabled'] = '["allowed", "google/gemini-2.5-flash"]';
|
|
const roster = await models.getAvailableModelsWithOverrides(f.db);
|
|
assert.ok(roster.length); assert.ok(!roster.some(m => ['allowed', 'google/gemini-2.5-flash'].includes(m.id)));
|
|
f.state.settings['models.disabled'] = JSON.stringify(models.getAvailableModels().map(m => m.id).concat(['allowed', 'other']));
|
|
assert.equal((await models.getAllowedModelIds(f.db)).size, 0);
|
|
});
|
|
|
|
test('real server model API/default setters/toggles/removal agree, and admin probe bypass is protected', async t => {
|
|
const f = fixture({ LITELLM_DEFAULT_MODEL: 'absent' }); const request = await f.serve(t, true);
|
|
const put = (route, body, role = 'admin') => request(route, { method: 'PUT', body, role });
|
|
assert.equal((await put('/api/admin/config/models/default', { modelId: 'absent' })).status, 400);
|
|
assert.equal((await put('/api/admin/config/models/toggle', { modelId: 'allowed', enabled: 'false' })).status, 400);
|
|
assert.equal((await put('/api/admin/config/models/toggle', { modelId: 'absent', enabled: false })).status, 400);
|
|
assert.equal((await put('/api/admin/config/models/toggle', { modelId: 'allowed', enabled: false })).status, 200);
|
|
assert.equal((await put('/api/admin/config/models/default', { modelId: 'allowed' })).status, 400);
|
|
let advertised = (await request('/api/models')).data;
|
|
assert.deepEqual(advertised.models.map(m => m.id), ['other']); assert.equal(advertised.defaultModel, 'other');
|
|
assert.equal(f.state.settings['models.default'], '');
|
|
assert.equal((await put('/api/admin/config/models/toggle', { modelId: 'allowed', enabled: true })).status, 200);
|
|
assert.equal((await put('/api/admin/config/models/default', { modelId: 'allowed' })).status, 200);
|
|
await request('/api/admin/config/models/custom/allowed', { method: 'DELETE', role: 'admin' });
|
|
advertised = (await request('/api/models')).data;
|
|
assert.deepEqual(advertised.models.map(m => m.id), ['other']); assert.equal(advertised.defaultModel, 'other');
|
|
const note = await request('/api/notes/from-voice', { method: 'POST', role: 'user', body: { transcript: 'Synthetic personal note' } });
|
|
assert.equal(note.status, 200); assert.equal(note.data.model, 'other', 'omitted note model uses final default, not disabled env fallback');
|
|
assert.equal((await put('/api/admin/config/models.default', { value: 'absent' })).status, 400);
|
|
assert.equal((await request('/api/admin/config/models/test', { method: 'POST', role: 'user', body: { modelId: 'absent' } })).status, 403);
|
|
const probe = await request('/api/admin/config/models/test', { method: 'POST', role: 'admin', body: { modelId: 'absent' } });
|
|
assert.equal(probe.data.success, true); assert.equal(f.state.requests.at(-1).model, 'absent');
|
|
f.state.settings['models.custom'] = '[]';
|
|
assert.deepEqual((await request('/api/models')).data.models, []);
|
|
assert.equal((await request('/api/models')).data.defaultModel, '');
|
|
f.state.settingsError = true;
|
|
const error = await request('/api/models'); assert.equal(error.status, 503); assert.deepEqual(error.data.models, []);
|
|
});
|
|
|
|
test('feature routes deny before data/provider access; user status is nonsensitive and announcements/moderator mounts stay protected', async t => {
|
|
const f = fixture(); const request = await f.serve(t, true);
|
|
for (const name of ['read_aloud', 'nextcloud', 'memories']) f.state.settings['feature.' + name] = 'false';
|
|
const endpoints = [
|
|
['POST', '/api/text-to-speech'], ['POST', '/api/nextcloud/connect'], ['POST', '/api/nextcloud/export'], ['POST', '/api/nextcloud/disconnect'],
|
|
['GET', '/api/memories'], ['GET', '/api/memories/context'], ['POST', '/api/memories'], ['PUT', '/api/memories/1'], ['DELETE', '/api/memories/1'],
|
|
['GET', '/api/admin/learning/webdav-browse'], ['POST', '/api/admin/learning/ai-generate'], ['POST', '/api/user/webdav-path'], ['POST', '/api/admin/learning/webdav-path']
|
|
];
|
|
for (const [method, route] of endpoints) {
|
|
const response = await request(route, { method, authenticated: true, body: method === 'GET' ? undefined : { webdavPath: '/synthetic.txt', text: 'Synthetic' } });
|
|
assert.equal(response.status, 403, route);
|
|
}
|
|
assert.equal(f.state.writes.length, 0); assert.equal(f.state.requests.length, 0);
|
|
assert.ok(!f.state.queries.some(q => q.includes('user_memories') || q.includes('nextcloud_token')));
|
|
const clinical = await request('/api/clinical-assistant/status', { role: 'user' });
|
|
assert.equal(clinical.status, 200); assert.equal(clinical.data.success, true, 'personal Nextcloud flag does not block clinical MCP status');
|
|
const features = await request('/api/user/features', { role: 'user' });
|
|
assert.deepEqual(features.data, { features: { read_aloud: false, nextcloud: false, memories: false } });
|
|
assert.equal((await request('/api/user/features')).status, 401);
|
|
f.state.settings['announcement.enabled'] = 'true'; f.state.settings['announcement.text'] = 'Synthetic announcement';
|
|
const announcement = await request('/api/admin/config/announcement', { role: 'user' });
|
|
assert.equal(announcement.status, 200); assert.equal(announcement.data.text, 'Synthetic announcement');
|
|
assert.equal((await request('/api/admin/config/announcement')).status, 401);
|
|
assert.equal((await request('/api/admin/config', { role: 'user' })).status, 403);
|
|
assert.equal((await request('/api/admin/config', { role: 'moderator' })).status, 403);
|
|
f.state.aiContent = '{"title":"Synthetic","body":"<p>Synthetic</p>","category_name":"General"}';
|
|
const generated = await request('/api/admin/learning/ai-generate', { method: 'POST', role: 'moderator', body: { topic: 'Synthetic educational topic' } });
|
|
assert.equal(generated.status, 200); assert.equal(generated.data.success, true);
|
|
assert.equal((await request('/api/admin/learning/ai-generate', { method: 'POST', role: 'user', body: { topic: 'Synthetic' } })).status, 403);
|
|
f.state.settings['feature.memories'] = 'true';
|
|
assert.equal((await request('/api/memories', { role: 'user' })).data.memories.length, 1);
|
|
assert.match((await request('/api/memories/context', { role: 'user' })).data.context, /MEMORY_SENTINEL/);
|
|
f.state.settingsError = true;
|
|
assert.equal((await request('/api/text-to-speech', { method: 'POST', authenticated: true, body: { text: 'Synthetic' } })).status, 503);
|
|
});
|
|
|
|
test('every actual generation memory consumer omits disabled templates and includes enabled templates', async t => {
|
|
const f = fixture(); const request = await f.serve(t, true);
|
|
const endpoints = ['/api/generate-hpi-encounter', '/api/generate-hpi-dictation', '/api/generate-soap', '/api/generate-hospital-course', '/api/well-visit/note', '/api/sick-visit/note', '/api/ed-encounters/generate'];
|
|
for (const enabled of [false, true]) {
|
|
f.state.settings['feature.memories'] = String(enabled);
|
|
for (const route of endpoints) {
|
|
const start = f.state.requests.length;
|
|
const response = await request(route, { method: 'POST', role: 'user', body: {
|
|
transcript: 'Synthetic clinical transcript', chiefComplaint: 'Synthetic concern', patientAge: '5 years',
|
|
notes: [{ date: '2026-01-01', content: 'Synthetic note' }], physicianMemories: 'MEMORY_SENTINEL'
|
|
} });
|
|
assert.equal(response.status, 200, route + ': ' + JSON.stringify(response.data));
|
|
assert.ok(f.state.requests.length > start, route);
|
|
assert.equal(JSON.stringify(f.state.requests.at(-1).messages).includes('MEMORY_SENTINEL'), enabled, route);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('TTS blank/incompatible user voice falls through to admin/env/family, and STT tests do not claim HTTP errors as success', async t => {
|
|
const f = fixture({ LITELLM_TTS_VOICE: 'Rosie' }); const request = await f.serve(t, true);
|
|
for (const [voice, admin, expected] of [[undefined, 'Luna', 'Luna'], ['', '', 'Rosie'], [' ', 'Luna', 'Luna'], ['F1', 'Luna', 'Luna'], ['Bella', 'Luna', 'Bella']]) {
|
|
f.state.user.tts_voice = voice; f.state.settings['tts.voice'] = admin;
|
|
const result = await request('/api/text-to-speech', { method: 'POST', role: 'user', body: { text: 'Synthetic' } });
|
|
assert.equal(result.status, 200); assert.equal(JSON.parse(f.state.requests.at(-1).options.body).voice, expected);
|
|
}
|
|
f.state.settings['tts.model'] = 'local-supertonic-tts'; f.state.settings['tts.voice'] = ''; f.state.user.tts_voice = '';
|
|
await request('/api/text-to-speech', { method: 'POST', role: 'user', body: { text: 'Synthetic' } });
|
|
assert.equal(JSON.parse(f.state.requests.at(-1).options.body).voice, 'F1');
|
|
const sttBody = { audioBase64: Buffer.from('synthetic').toString('base64') };
|
|
f.state.httpError = 401;
|
|
const bad = await request('/api/admin/config/stt/test', { method: 'POST', role: 'admin', body: sttBody });
|
|
assert.equal(bad.data.success, false); assert.match(bad.data.error, /401/);
|
|
f.state.httpError = null;
|
|
const good = await request('/api/admin/config/stt/test', { method: 'POST', role: 'admin', body: sttBody });
|
|
assert.equal(good.data.success, true); assert.equal(good.data.text, 'synthetic transcript');
|
|
});
|
|
|
|
test('both actual directory file routes enforce parser bound and retain 1 MiB upload limit', async t => {
|
|
const f = fixture(); const request = await f.serve(t, true);
|
|
const transfer = require('../src/utils/extensionTransfer');
|
|
const payload = transfer.exportPayload([{ location: 'Synthetic', name: 'Desk', number: '123' }]);
|
|
const compressed = require('node:zlib').deflateRawSync(Buffer.from(JSON.stringify('x'.repeat(4 * 1024 * 1024 + 1))));
|
|
const header = Buffer.alloc(31);
|
|
header.writeUInt32LE(0x04034b50, 0); header.writeUInt16LE(20, 4); header.writeUInt16LE(8, 8);
|
|
header.writeUInt32LE(compressed.length, 18); header.writeUInt32LE(1, 22); header.writeUInt16LE(1, 26); header[30] = 97;
|
|
const bomb = Buffer.concat([header, compressed]);
|
|
for (const route of ['/api/extensions/import-file/preview', '/api/extensions/import-file']) {
|
|
for (const file of [Buffer.from(JSON.stringify(payload)), transfer.createJsonZip('directory.json', payload), bomb, Buffer.alloc(1024 * 1024 + 1, 32)]) {
|
|
const form = new FormData(); form.append('file', new Blob([file]), 'directory.zip');
|
|
const before = f.state.writes.length;
|
|
const response = await request(route, { method: 'POST', authenticated: true, body: form });
|
|
if (file === bomb || file.length > 1024 * 1024) {
|
|
assert.ok(response.status >= 400); assert.equal(f.state.writes.length, before);
|
|
} else { assert.equal(response.status, 200); assert.equal(response.data.success, true); }
|
|
}
|
|
}
|
|
});
|
|
|
|
test('Azure configured deployment cannot bypass final roster through provider URL or streaming model override', async () => {
|
|
const f = fixture({ AI_PROVIDER: 'azure', AZURE_OPENAI_ENDPOINT: 'https://azure.example', AZURE_OPENAI_API_KEY: 'synthetic', AZURE_DEPLOYMENT_NAME: 'blocked-deployment' });
|
|
const ai = f.load('src/utils/ai.js');
|
|
for (const call of [o => ai.callAI([], o), o => ai.callAIStream([], o, () => {})]) {
|
|
await assert.rejects(call({ model: 'allowed' }), /Model not permitted/);
|
|
assert.equal(f.state.requests.length, 0);
|
|
}
|
|
});
|
|
|
|
test('enabled personal Nextcloud and memory CRUD remain usable through actual handlers with fake storage/WebDAV', async t => {
|
|
const f = fixture(); const request = await f.serve(t, true); f.state.allowDav = true;
|
|
f.state.user.nextcloud_url = 'https://cloud.example'; f.state.user.nextcloud_user = 'synthetic'; f.state.user.nextcloud_token = 'synthetic-token';
|
|
for (const [route, body] of [
|
|
['/api/nextcloud/connect', { nextcloudUrl: 'https://cloud.example', username: 'synthetic', appPassword: 'synthetic-token' }],
|
|
['/api/nextcloud/export', { content: 'Synthetic document', type: 'note' }],
|
|
['/api/user/webdav-path', { path: '/Synthetic' }],
|
|
['/api/nextcloud/disconnect', {}]
|
|
]) {
|
|
const response = await request(route, { method: 'POST', role: 'user', body });
|
|
assert.equal(response.status, 200, route); assert.equal(response.data.success, true);
|
|
}
|
|
const browse = await request('/api/admin/learning/webdav-browse', { role: 'moderator' });
|
|
assert.equal(browse.status, 200); assert.equal(browse.data.success, true);
|
|
assert.ok(f.state.requests.some(r => r.dav && r.dav.method === 'PUT'));
|
|
for (const [method, route] of [['POST', '/api/memories'], ['PUT', '/api/memories/1'], ['DELETE', '/api/memories/1']]) {
|
|
const response = await request(route, { method, role: 'user', body: { name: 'Synthetic template', category: 'physical_exam', content: 'Synthetic content' } });
|
|
assert.equal(response.status, 200); assert.equal(response.data.success, true);
|
|
}
|
|
});
|
|
|
|
test('LearningAI saved WebDAV path requires enabled Nextcloud for admins and moderators, without writes on denial/outage', async t => {
|
|
const f = fixture(); const request = await f.serve(t, true);
|
|
for (const role of ['admin', 'moderator']) {
|
|
for (const unavailable of [false, true]) {
|
|
f.state.settings['feature.nextcloud'] = 'false';
|
|
f.state.settingsError = unavailable;
|
|
const before = f.state.writes.length;
|
|
const response = await request('/api/admin/learning/webdav-path', { method: 'POST', role, body: { path: '/Synthetic' } });
|
|
assert.equal(response.status, unavailable ? 503 : 403);
|
|
assert.equal(f.state.writes.length, before);
|
|
assert.equal(f.state.requests.length, 0);
|
|
}
|
|
f.state.settingsError = false;
|
|
f.state.settings['feature.nextcloud'] = 'true';
|
|
const before = f.state.writes.length;
|
|
const response = await request('/api/admin/learning/webdav-path', { method: 'POST', role, body: { path: '/Synthetic' } });
|
|
assert.equal(response.status, 200); assert.equal(response.data.success, true);
|
|
assert.equal(f.state.writes.length, before + 1);
|
|
assert.match(f.state.writes.at(-1).sql, /UPDATE users SET webdav_learning_path/);
|
|
assert.deepEqual(Array.from(f.state.writes.at(-1).params), ['/Synthetic', 7]);
|
|
}
|
|
const before = f.state.writes.length;
|
|
assert.equal((await request('/api/admin/learning/webdav-path', { method: 'POST', role: 'user', body: { path: '/Synthetic' } })).status, 403);
|
|
assert.equal(f.state.writes.length, before);
|
|
});
|
|
|
|
test('actual native admin script disables selected default, displays backend replacement, and saves it; discovery excludes disabled options', async t => {
|
|
const { JSDOM } = require('jsdom');
|
|
const { pathToFileURL } = require('node:url');
|
|
const f = fixture({ AI_PROVIDER: 'openrouter' }); const request = await f.serve(t, true);
|
|
const models = f.load('src/utils/models.js').getAvailableModels();
|
|
const selected = models[0].id;
|
|
f.state.settings['models.default'] = selected;
|
|
f.state.settings['models.disabled'] = JSON.stringify([models[1].id, 'allowed']);
|
|
const dom = new JSDOM(fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8'), { url: 'https://app.example' });
|
|
const originals = new Map(['window', 'document', 'fetch', 'getAuthHeaders', 'showToast'].map(key => [key, Object.getOwnPropertyDescriptor(global, key)]));
|
|
t.after(() => { for (const [key, value] of originals) { if (value) Object.defineProperty(global, key, value); else delete global[key]; } dom.window.close(); });
|
|
const toasts = []; const calls = [];
|
|
Object.assign(global, { window: dom.window, document: dom.window.document, getAuthHeaders: () => ({}), showToast: (...args) => toasts.push(args) });
|
|
global.fetch = async (url, options = {}) => {
|
|
calls.push(url);
|
|
if (url === '/api/admin/config/models' || url.startsWith('/api/admin/config/models/')) {
|
|
if (url.includes('/discover?')) return { json: async () => ({ success: true, count: 1, models: [{ id: 'discovered', name: 'Discovered' }] }) };
|
|
const response = await request(url, { method: options.method || 'GET', role: 'admin', body: options.body ? JSON.parse(options.body) : undefined });
|
|
return { json: async () => response.data };
|
|
}
|
|
return { json: async () => ({}) }; // unrelated admin panels are outside this regression
|
|
};
|
|
await import(pathToFileURL(path.join(root, 'public/js/admin.js')).href);
|
|
// Native Node imports share global.fetch; preserve loopback transport for the real handlers.
|
|
const browserFetch = global.fetch;
|
|
global.fetch = async (url, options) => String(url).startsWith('http://127.0.0.1:')
|
|
? originals.get('fetch').value(url, options) : browserFetch(url, options);
|
|
const waitFor = async predicate => {
|
|
for (let i = 0; i < 100; i++) { if (predicate()) return; await new Promise(resolve => setTimeout(resolve, 5)); }
|
|
assert.fail('Admin UI did not settle');
|
|
};
|
|
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
|
const select = document.getElementById('admin-default-model');
|
|
await waitFor(() => select.value === selected);
|
|
const assertDisabledAbsent = () => {
|
|
for (const id of JSON.parse(f.state.settings['models.disabled'])) assert.ok(!Array.from(select.options).some(o => o.value === id), id);
|
|
};
|
|
assertDisabledAbsent();
|
|
const cb = document.querySelector('.admin-model-toggle[data-model-id="' + selected + '"]');
|
|
cb.checked = false; cb.dispatchEvent(new dom.window.Event('change'));
|
|
await waitFor(() => select.value && select.value !== selected);
|
|
const advertised = await request('/api/models');
|
|
assert.equal(select.value, advertised.data.defaultModel); assertDisabledAbsent();
|
|
document.getElementById('btn-save-default-model').click();
|
|
await waitFor(() => toasts.some(([message]) => message.startsWith('Default model set:')));
|
|
assert.equal(f.state.settings['models.default'], select.value);
|
|
assert.equal(calls.filter(url => url === '/api/admin/config/models').length, 2, 'successful toggle refreshes models');
|
|
document.getElementById('btn-discover-models').click();
|
|
await waitFor(() => document.querySelector('.admin-add-discovered'));
|
|
document.querySelector('.admin-add-discovered').click();
|
|
await waitFor(() => select.value === 'discovered');
|
|
assertDisabledAbsent();
|
|
assert.ok(Array.from(select.options).some(o => o.value === 'other'), 'enabled custom model retained');
|
|
});
|
|
|
|
const readSource = file => fs.readFileSync(path.join(root, file), 'utf8');
|
|
|
|
test('a router mounted on /api must not gate the whole namespace', () => {
|
|
// These routers are all mounted on '/api', so `router.use(authMiddleware)`
|
|
// with no path applies to every /api request that reaches them — including
|
|
// routes owned by routers mounted further down server.js. extensions.js did
|
|
// exactly that from line 295, which is why the signed-out assistant preview
|
|
// returned 401 no matter what the admin setting said: the request never got
|
|
// as far as the preview middleware. Each gate must name its own prefix.
|
|
const server = readSource('server.js');
|
|
const mounted = [...server.matchAll(/app\.use\('\/api', require\('\.\/src\/routes\/([\w-]+)'\)\)/g)]
|
|
.map(m => m[1]);
|
|
assert.ok(mounted.length > 10, 'expected the /api routers to be found');
|
|
for (const name of mounted) {
|
|
let source;
|
|
try { source = readSource('src/routes/' + name + '.js'); } catch (e) { continue; }
|
|
assert.doesNotMatch(source, /^router\.use\(\s*authMiddleware\s*\)/m,
|
|
name + '.js gates every /api path; scope it, e.g. router.use(\'/' + name + '\', authMiddleware)');
|
|
}
|
|
});
|
|
|
|
test('the signed-out preview is reachable, and stays narrow', () => {
|
|
const route = readSource('src/routes/clinicalAssistant.js');
|
|
// authMiddleware only ever looks for a token, so calling it unconditionally
|
|
// after the preview identity was assigned rejected the very requests preview
|
|
// exists to serve. Only the preview identity may skip it.
|
|
assert.match(route, /if \(req\.user && req\.user\.preview\) return next\(\);/);
|
|
assert.match(route, /return authMiddleware\(req, res, next\);/);
|
|
// Allow-listed by exact path: a route added later is private unless someone
|
|
// puts it on this list deliberately.
|
|
const list = route.slice(route.indexOf('var PREVIEW_PATHS'), route.indexOf('var PREVIEW_USER'));
|
|
assert.match(list, /'\/clinical-assistant\/chat'/);
|
|
assert.match(list, /'\/clinical-assistant\/chat\/stream'/);
|
|
assert.doesNotMatch(list, /saved-chats|\/config|\/images/);
|
|
// A preview visitor has no identity, so nothing can be owned or billed.
|
|
assert.match(route, /PREVIEW_USER = Object\.freeze\(\{ id: null, preview: true/);
|
|
});
|