556 lines
37 KiB
JavaScript
556 lines
37 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');
|
|
});
|