hub-settings-chat-provider.test.mjs
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