hub-settings-chat-provider.test.mjs
155 lines 6.7 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Self-hosted Hub chat-provider settings: POST /api/v1/settings/chat + GET /api/v1/settings `chat`.
3 *
4 * The endpoint lets an admin select the completeChat provider (MCP summarize + Hub proposal LLM
5 * jobs) from the UI, persisting llm.provider to config/local.yaml. The provider drives where note
6 * text is sent and which account is billed, so input is strictly whitelisted and the operator env
7 * lock (KNOWTATION_CHAT_PROVIDER) always wins.
8 *
9 * Validation logic is covered live via normalizeChatProviderInput; route wiring/authz/persistence
10 * is covered by source inspection (the established pattern for these Hub routes).
11 */
12 import { describe, it } from 'node:test';
13 import assert from 'node:assert/strict';
14 import fs from 'fs';
15 import path from 'path';
16 import { fileURLToPath } from 'url';
17 import { normalizeChatProviderInput, CHAT_PROVIDERS } from '../lib/config.mjs';
18
19 const __dirname = path.dirname(fileURLToPath(import.meta.url));
20 const repoRoot = path.dirname(__dirname);
21
22 function readRepoFile(relativePath) {
23 return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
24 }
25
26 function chatRouteSource() {
27 const src = readRepoFile('hub/server.mjs');
28 const start = src.indexOf('// POST /api/v1/settings/chat');
29 const end = src.indexOf('function validateMuseUrlForYaml', start);
30 assert.notEqual(start, -1, 'chat settings route must exist');
31 assert.notEqual(end, -1, 'route must stay before validateMuseUrlForYaml');
32 return src.slice(start, end);
33 }
34
35 describe('chat-provider settings — unit (normalizeChatProviderInput)', () => {
36 it('accepts empty / null as "" (auto-detect)', () => {
37 assert.deepEqual(normalizeChatProviderInput(''), { ok: true, provider: '' });
38 assert.deepEqual(normalizeChatProviderInput(null), { ok: true, provider: '' });
39 assert.deepEqual(normalizeChatProviderInput(undefined), { ok: true, provider: '' });
40 });
41
42 it('accepts every whitelisted provider (case-insensitive, trimmed)', () => {
43 for (const p of CHAT_PROVIDERS) {
44 assert.deepEqual(normalizeChatProviderInput(` ${p.toUpperCase()} `), { ok: true, provider: p });
45 }
46 });
47
48 it('rejects unknown providers with an actionable error', () => {
49 const r = normalizeChatProviderInput('gemini');
50 assert.equal(r.ok, false);
51 assert.match(r.error, /must be one of/);
52 });
53
54 it('rejects non-string input', () => {
55 assert.equal(normalizeChatProviderInput(42).ok, false);
56 assert.equal(normalizeChatProviderInput({ provider: 'openai' }).ok, false);
57 assert.equal(normalizeChatProviderInput(['openai']).ok, false);
58 });
59
60 it('rejects injection-style values (never written to YAML / never reaches completeChat)', () => {
61 assert.equal(normalizeChatProviderInput('openai; rm -rf /').ok, false);
62 assert.equal(normalizeChatProviderInput('../../etc/passwd').ok, false);
63 assert.equal(normalizeChatProviderInput('openai\nprovider: anthropic').ok, false);
64 });
65 });
66
67 describe('chat-provider settings — integration (route wiring)', () => {
68 it('registers POST /api/v1/settings/chat behind jwtAuth, rate limit, admin role, and json body', () => {
69 const route = chatRouteSource();
70 assert.match(route, /app\.post\(\s*'\/api\/v1\/settings\/chat'/);
71 assert.match(route, /jwtAuth/);
72 assert.match(route, /apiLimiter/);
73 assert.match(route, /requireRole\('admin'\)/);
74 assert.match(route, /express\.json\(\)/);
75 });
76
77 it('validates input with normalizeChatProviderInput and persists llm.provider to local.yaml', () => {
78 const route = chatRouteSource();
79 assert.match(route, /normalizeChatProviderInput\(body\.provider\)/);
80 assert.match(route, /doc\.llm\.provider = result\.provider/);
81 assert.match(route, /fs\.writeFileSync\(configPath, yaml\.dump\(doc\)/);
82 assert.match(route, /config = loadConfig\(projectRoot\)/);
83 });
84
85 it('exposes the chat block in GET /api/v1/settings', () => {
86 const src = readRepoFile('hub/server.mjs');
87 assert.match(src, /chat: \{/);
88 assert.match(src, /provider: config\.llm\?\.provider \|\| ''/);
89 assert.match(src, /providers: CHAT_PROVIDERS/);
90 assert.match(src, /env_locked: Boolean\(process\.env\.KNOWTATION_CHAT_PROVIDER\)/);
91 assert.match(src, /key_available:/);
92 });
93 });
94
95 describe('chat-provider settings — end-to-end (response shape contract)', () => {
96 it('returns the resolved provider after save', () => {
97 const route = chatRouteSource();
98 assert.match(route, /res\.json\(\{ ok: true, chat: \{ provider: config\.llm\?\.provider \|\| '' \} \}\)/);
99 });
100 });
101
102 describe('chat-provider settings — stress (bounded source surface)', () => {
103 it('route checks stay bounded to server + config sources', () => {
104 const started = Date.now();
105 const sources = [readRepoFile('hub/server.mjs'), readRepoFile('lib/config.mjs')];
106 assert.equal(sources.length, 2);
107 assert.ok(Date.now() - started < 300);
108 });
109 });
110
111 describe('chat-provider settings — data integrity', () => {
112 it('writes only the validated provider value (no echo of raw body, no other llm fields)', () => {
113 const route = chatRouteSource();
114 // The only llm field written is provider, sourced from the validator result.
115 assert.match(route, /doc\.llm\.provider = result\.provider/);
116 assert.doesNotMatch(route, /doc\.llm\.\w+ = body\./);
117 });
118
119 it('initialises doc.llm defensively before writing', () => {
120 const route = chatRouteSource();
121 assert.match(route, /if \(!doc\.llm \|\| typeof doc\.llm !== 'object'\) doc\.llm = \{\}/);
122 });
123 });
124
125 describe('chat-provider settings — performance', () => {
126 it('the route performs no model/network call (settings write only)', () => {
127 const route = chatRouteSource();
128 assert.doesNotMatch(route, /completeChat\(|fetch\(|embedWithUsage\(|runSearch\(/);
129 });
130 });
131
132 describe('chat-provider settings — security', () => {
133 it('honours the operator env lock with a 409 before any write', () => {
134 const route = chatRouteSource();
135 assert.match(route, /if \(process\.env\.KNOWTATION_CHAT_PROVIDER\)/);
136 assert.match(route, /status\(409\)/);
137 assert.match(route, /ENV_LOCKED/);
138 // The env-lock guard appears before the writeFileSync.
139 assert.ok(route.indexOf('ENV_LOCKED') < route.indexOf('writeFileSync'));
140 });
141
142 it('is admin-only (no viewer/editor/evaluator access)', () => {
143 const route = chatRouteSource();
144 assert.match(route, /requireRole\('admin'\)/);
145 assert.doesNotMatch(route, /requireRole\('viewer'|requireRole\('editor'/);
146 });
147
148 it('rejects invalid input with 400 VALIDATION_ERROR rather than writing it', () => {
149 const route = chatRouteSource();
150 assert.match(route, /if \(!result\.ok\)/);
151 assert.match(route, /status\(400\)/);
152 assert.match(route, /VALIDATION_ERROR/);
153 assert.ok(route.indexOf('VALIDATION_ERROR') < route.indexOf('writeFileSync'));
154 });
155 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago