phase3-security.test.mjs
517 lines 24.6 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * Phase 3 Security Remediation Tests
3 *
4 * Covers all 6 Phase 3 items from docs/SECURITY-AUDIT-PLAN.md:
5 * 3.1 — JWT token-in-URL: OAuth redirect uses URL fragment (#token=); gateway JWT expiry shortened from 7d
6 * 3.2 — Image proxy: short-lived HMAC-signed token replaces full JWT in ?token= query param
7 * 3.3 — Bridge write routes: requireBridgeEditorOrAdmin guards all mutation endpoints
8 * 3.4 — MCP refresh tokens: durable createGatewayRefreshStore (not in-memory Map)
9 * 3.5 — CORS on canister: corsHeaders() locks origin when gateway_auth_secret is set (Motoko structural)
10 * 3.6 — path-to-regexp ReDoS CVE resolved (npm audit passes)
11 */
12
13 import { test, describe } from 'node:test';
14 import assert from 'node:assert/strict';
15 import fs from 'node:fs';
16 import path from 'node:path';
17 import crypto from 'node:crypto';
18 import { fileURLToPath } from 'node:url';
19
20 const __dirname = path.dirname(fileURLToPath(import.meta.url));
21 const ROOT = path.resolve(__dirname, '..');
22
23 // ---------------------------------------------------------------------------
24 // 3.1 JWT token-in-URL: fragment-based redirect + shortened expiry
25 // ---------------------------------------------------------------------------
26 describe('3.1 JWT token-in-URL: OAuth redirects use fragment, gateway expiry shortened', () => {
27 let gatewaySource;
28 let selfHostedSource;
29
30 const loadGateway = () => {
31 if (!gatewaySource) gatewaySource = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
32 return gatewaySource;
33 };
34 const loadSelfHosted = () => {
35 if (!selfHostedSource) selfHostedSource = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8');
36 return selfHostedSource;
37 };
38
39 test('gateway postLoginRedirect uses # fragment, not ?token= query param', () => {
40 const src = loadGateway();
41 assert.ok(src.includes('/hub/#'), 'postLoginRedirect must redirect to #fragment');
42 const fnBlock = src.slice(src.indexOf('function postLoginRedirect'));
43 const fnEnd = fnBlock.indexOf('\n}');
44 const fnBody = fnBlock.slice(0, fnEnd);
45 assert.ok(!fnBody.includes('?token='), 'postLoginRedirect must NOT use ?token= query');
46 });
47
48 test('gateway JWT_EXPIRY default is no longer 7d', () => {
49 const src = loadGateway();
50 const match = src.match(/JWT_EXPIRY\s*=\s*process\.env\.HUB_JWT_EXPIRY\s*\|\|\s*'([^']+)'/);
51 assert.ok(match, 'JWT_EXPIRY constant must exist with default');
52 assert.notEqual(match[1], '7d', 'default JWT_EXPIRY must not be 7d');
53 assert.equal(match[1], '24h', 'default JWT_EXPIRY should be 24h');
54 });
55
56 test('self-hosted handleAuthCallback uses # fragment, not ?token= query param', () => {
57 const src = loadSelfHosted();
58 assert.ok(
59 src.includes('/#token=') || src.includes("'/#token='"),
60 'self-hosted redirect must use # fragment for token'
61 );
62 const postRedirectBlock = src.slice(src.indexOf('function handleAuthCallback'));
63 assert.ok(
64 !postRedirectBlock.includes('/?token='),
65 'handleAuthCallback must NOT use ?token= query param'
66 );
67 });
68 });
69
70 // ---------------------------------------------------------------------------
71 // 3.2 Image proxy: short-lived HMAC-signed token
72 // ---------------------------------------------------------------------------
73 describe('3.2 Image proxy: HMAC-signed token replaces full JWT in query param', () => {
74 const SECRET = 'test-secret-key-for-phase3-tests';
75 const UID = 'google:123456';
76
77 function signImageProxyToken(secret, uid) {
78 const TTL = 300;
79 const exp = Math.floor(Date.now() / 1000) + TTL;
80 const payload = `img\0${uid}\0${exp}`;
81 const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
82 return `${exp}.${Buffer.from(uid).toString('base64url')}.${sig}`;
83 }
84
85 function verifyImageProxyToken(secret, token) {
86 if (typeof token !== 'string') return null;
87 const parts = token.split('.');
88 if (parts.length !== 3) return null;
89 const [expStr, uidB64, sig] = parts;
90 const exp = parseInt(expStr, 10);
91 if (!exp || Math.floor(Date.now() / 1000) > exp) return null;
92 let uid;
93 try { uid = Buffer.from(uidB64, 'base64url').toString(); } catch (_) { return null; }
94 if (!uid) return null;
95 const payload = `img\0${uid}\0${exp}`;
96 const expected = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
97 const sigBuf = Buffer.from(sig);
98 const expectedBuf = Buffer.from(expected);
99 if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) return null;
100 return uid;
101 }
102
103 test('signImageProxyToken produces a 3-part dot-separated token', () => {
104 const token = signImageProxyToken(SECRET, UID);
105 const parts = token.split('.');
106 assert.equal(parts.length, 3, 'token must have 3 parts: exp.uid_b64.sig');
107 });
108
109 test('verifyImageProxyToken returns uid for a valid token', () => {
110 const token = signImageProxyToken(SECRET, UID);
111 const result = verifyImageProxyToken(SECRET, token);
112 assert.equal(result, UID);
113 });
114
115 test('verifyImageProxyToken rejects tampered signature', () => {
116 const token = signImageProxyToken(SECRET, UID);
117 const tampered = token.slice(0, -4) + 'XXXX';
118 assert.equal(verifyImageProxyToken(SECRET, tampered), null);
119 });
120
121 test('verifyImageProxyToken rejects wrong secret', () => {
122 const token = signImageProxyToken(SECRET, UID);
123 assert.equal(verifyImageProxyToken('wrong-secret', token), null);
124 });
125
126 test('verifyImageProxyToken rejects expired token', () => {
127 const exp = Math.floor(Date.now() / 1000) - 10;
128 const payload = `img\0${UID}\0${exp}`;
129 const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('base64url');
130 const token = `${exp}.${Buffer.from(UID).toString('base64url')}.${sig}`;
131 assert.equal(verifyImageProxyToken(SECRET, token), null);
132 });
133
134 test('verifyImageProxyToken rejects invalid format', () => {
135 assert.equal(verifyImageProxyToken(SECRET, ''), null);
136 assert.equal(verifyImageProxyToken(SECRET, 'not.a.valid.token'), null);
137 assert.equal(verifyImageProxyToken(SECRET, null), null);
138 assert.equal(verifyImageProxyToken(SECRET, undefined), null);
139 });
140
141 test('gateway server has image-proxy-token signing endpoint', () => {
142 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
143 assert.ok(src.includes("'/api/v1/vault/image-proxy-token'"), 'gateway must expose image-proxy-token endpoint');
144 assert.ok(src.includes('signImageProxyToken'), 'gateway must use signImageProxyToken');
145 });
146
147 test('self-hosted server has image-proxy-token signing endpoint', () => {
148 const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8');
149 assert.ok(src.includes("'/api/v1/vault/image-proxy-token'"), 'self-hosted must expose image-proxy-token endpoint');
150 assert.ok(src.includes('signImageProxyToken'), 'self-hosted must use signImageProxyToken');
151 });
152
153 test('gateway image proxy uses verifyImageProxyToken for query token auth', () => {
154 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
155 assert.ok(src.includes('verifyImageProxyToken'), 'gateway image proxy must use verifyImageProxyToken');
156 });
157
158 test('gateway image proxy has backward-compat JWT fallback for ?token=', () => {
159 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
160 assert.ok(src.includes('Backward compat'), 'gateway must include JWT fallback for pre-signed-token hub.js');
161 });
162
163 test('self-hosted image proxy has backward-compat JWT fallback for ?token=', () => {
164 const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8');
165 assert.ok(src.includes('Backward compat'), 'self-hosted must include JWT fallback for pre-signed-token hub.js');
166 });
167 });
168
169 // ---------------------------------------------------------------------------
170 // 3.3 Bridge write routes: requireBridgeEditorOrAdmin on mutations
171 // ---------------------------------------------------------------------------
172 describe('3.3 Bridge write routes guarded by requireBridgeEditorOrAdmin', () => {
173 let bridgeSrc;
174 const load = () => {
175 if (!bridgeSrc) bridgeSrc = fs.readFileSync(path.join(ROOT, 'hub/bridge/server.mjs'), 'utf8');
176 return bridgeSrc;
177 };
178
179 test('POST /api/v1/vault/sync has requireBridgeEditorOrAdmin', () => {
180 const src = load();
181 const syncLine = src.split('\n').find((l) => l.includes("'/api/v1/vault/sync'") && l.includes('app.post'));
182 assert.ok(syncLine, 'sync route must exist');
183 assert.ok(syncLine.includes('requireBridgeEditorOrAdmin'), '/vault/sync must require editor or admin');
184 });
185
186 test('POST /api/v1/index has requireBridgeEditorOrAdmin', () => {
187 const src = load();
188 const indexLine = src.split('\n').find((l) => l.includes("'/api/v1/index'") && l.includes('app.post'));
189 assert.ok(indexLine, 'index route must exist');
190 assert.ok(indexLine.includes('requireBridgeEditorOrAdmin'), '/index must require editor or admin');
191 });
192
193 test('POST /api/v1/index removes stale rows so search cannot return paths no longer in the export', () => {
194 // Contract intent: after `feat/bridge-embed-hash-cache`, the bridge does incremental
195 // indexing rather than blind delete-then-upsert. Same semantic guarantee, two paths:
196 // - empty vault → store.deleteByVaultId(vaultId) clears every row for the vault;
197 // - non-empty vault → store.deleteByChunkIds(orphanIds) removes chunk_ids in the
198 // store that are absent from the current export (deleted notes / renamed paths).
199 // Both calls must remain in the source so a future refactor cannot silently regress
200 // the security property that prompted this test.
201 const src = load();
202 assert.ok(
203 src.includes('store.deleteByVaultId(vaultId)'),
204 'bridge index must call store.deleteByVaultId(vaultId) on the empty / first-run path',
205 );
206 assert.ok(
207 src.includes('store.deleteByChunkIds(partitioned.orphanIds)'),
208 'bridge index must call store.deleteByChunkIds(partitioned.orphanIds) for incremental orphan cleanup',
209 );
210 assert.ok(
211 src.includes('search cannot return paths') ||
212 src.includes('search cannot return paths no longer in the export'),
213 'bridge index must keep the comment explaining the search-orphan invariant',
214 );
215 });
216
217 test('POST /api/v1/index JSON includes vectors_deleted for operators', () => {
218 const src = load();
219 assert.ok(
220 src.includes('vectors_deleted') && src.includes('chunksIndexed') && src.includes('notesProcessed'),
221 'bridge index response must expose vectors_deleted alongside notesProcessed/chunksIndexed',
222 );
223 });
224
225 test('GET /api/v1/bridge-version exists for deploy verification', () => {
226 const src = load();
227 assert.ok(
228 src.includes("app.get('/api/v1/bridge-version'") && src.includes('COMMIT_REF'),
229 'bridge must expose unauthenticated GET /api/v1/bridge-version with commit metadata',
230 );
231 });
232
233 test('POST /api/v1/memory/store has requireBridgeEditorOrAdmin', () => {
234 const src = load();
235 const storeLine = src.split('\n').find((l) => l.includes("'/api/v1/memory/store'") && l.includes('app.post'));
236 assert.ok(storeLine, 'memory/store route must exist');
237 assert.ok(storeLine.includes('requireBridgeEditorOrAdmin'), '/memory/store must require editor or admin');
238 });
239
240 test('DELETE /api/v1/memory/clear has requireBridgeEditorOrAdmin', () => {
241 const src = load();
242 const clearLine = src.split('\n').find((l) => l.includes("'/api/v1/memory/clear'") && l.includes('app.delete'));
243 assert.ok(clearLine, 'memory/clear route must exist');
244 assert.ok(clearLine.includes('requireBridgeEditorOrAdmin'), '/memory/clear must require editor or admin');
245 });
246
247 test('POST /api/v1/memory/consolidate has requireBridgeEditorOrAdmin', () => {
248 const src = load();
249 const consolLine = src.split('\n').find((l) => l.includes("'/api/v1/memory/consolidate'") && l.includes('app.post'));
250 assert.ok(consolLine, 'memory/consolidate route must exist');
251 assert.ok(consolLine.includes('requireBridgeEditorOrAdmin'), '/memory/consolidate must require editor or admin');
252 });
253
254 test('requireBridgeEditorOrAdmin blocks viewer role', () => {
255 const src = load();
256 const fnBlock = src.slice(src.indexOf('async function requireBridgeEditorOrAdmin'));
257 assert.ok(fnBlock.includes("role === 'viewer'"), 'middleware must check for viewer role');
258 assert.ok(fnBlock.includes('403'), 'middleware must return 403 for viewers');
259 });
260 });
261
262 // ---------------------------------------------------------------------------
263 // 3.4 MCP durable refresh store (Phase A) — no in-memory plaintext Map
264 // ---------------------------------------------------------------------------
265 describe('3.4 MCP refresh token store — durable gateway store', () => {
266 let mcpSrc;
267 const load = () => {
268 if (!mcpSrc) mcpSrc = fs.readFileSync(path.join(ROOT, 'hub/gateway/mcp-oauth-provider.mjs'), 'utf8');
269 return mcpSrc;
270 };
271
272 test('KnowtationOAuthProvider requires refreshStore (no in-memory _refreshTokens Map)', () => {
273 const src = load();
274 assert.ok(src.includes('requires refreshStore'), 'constructor must require refreshStore');
275 assert.ok(!src.includes('this._refreshTokens = new Map'), 'must not use in-memory refresh Map');
276 assert.ok(src.includes('DEFAULT_TOKEN_TTL_MS'), 'must align refresh TTL with refresh-token-core');
277 assert.ok(src.includes('DEFAULT_FAMILY_TTL_MS'), 'must align family TTL with refresh-token-core');
278 });
279
280 test('exchangeAuthorizationCode issues via refreshStore', () => {
281 const src = load();
282 assert.ok(src.includes('this._refreshStore.issue'), 'must issue durable refresh tokens');
283 assert.ok(src.includes("meta:"), 'must attach agent/client meta');
284 });
285
286 test('exchangeRefreshToken rotates via refreshStore', () => {
287 const src = load();
288 assert.ok(src.includes('this._refreshStore.rotate'), 'must rotate via durable store');
289 });
290
291 test('server wires MCP provider with shared refreshStore', () => {
292 const serverSrc = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
293 assert.ok(serverSrc.includes('refreshStore,'), 'must pass refreshStore into KnowtationOAuthProvider');
294 assert.ok(serverSrc.includes("consistency: 'strong'"), 'persistent host must use strong consistency');
295 });
296 });
297
298 // ---------------------------------------------------------------------------
299 // 3.4b MCP OAuth: SDK express-rate-limit behind Nginx (proxy validate relaxations)
300 // ---------------------------------------------------------------------------
301 describe('3.4b MCP OAuth: SDK rate limit behind Nginx', () => {
302 test('gateway disables express-rate-limit validations for mcpAuthRouter (keep limiters)', () => {
303 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
304 assert.ok(src.includes('app.set(\'trust proxy\', 1)'), 'gateway must set trust proxy for X-Forwarded-For');
305 const block = src.slice(src.indexOf('app._mcpOAuthProvider = oauthProvider'), src.indexOf('[gateway] MCP OAuth 2.1 endpoints mounted'));
306 assert.ok(
307 block.includes('rateLimit: { validate: false }'),
308 'must set rateLimit.validate false so ERR_ERL_* does not break /token behind Nginx',
309 );
310 assert.match(block, /authorizationOptions:\s*mcpOAuthSdkRateLimitOpts/);
311 assert.match(block, /tokenOptions:\s*mcpOAuthSdkRateLimitOpts/);
312 });
313 });
314
315 // ---------------------------------------------------------------------------
316 // 3.5 CORS on canister: locked origin when gateway_auth_secret is set
317 // ---------------------------------------------------------------------------
318 describe('3.5 Canister CORS locked to gateway origin when auth secret set', () => {
319 let mainMo;
320 let migrationMo;
321 const loadMain = () => {
322 if (!mainMo) mainMo = fs.readFileSync(path.join(ROOT, 'hub/icp/src/hub/main.mo'), 'utf8');
323 return mainMo;
324 };
325 const loadMigration = () => {
326 if (!migrationMo) migrationMo = fs.readFileSync(path.join(ROOT, 'hub/icp/src/hub/Migration.mo'), 'utf8');
327 return migrationMo;
328 };
329
330 test('corsHeaders() checks gateway_auth_secret and cors_allowed_origin', () => {
331 const src = loadMain();
332 const corsBlock = src.slice(src.indexOf('func corsHeaders'));
333 assert.ok(corsBlock.includes('gateway_auth_secret'), 'corsHeaders must check gateway_auth_secret');
334 assert.ok(corsBlock.includes('cors_allowed_origin'), 'corsHeaders must check cors_allowed_origin');
335 });
336
337 test('corsHeaders() returns specific origin when both secrets are set', () => {
338 const src = loadMain();
339 const corsBlock = src.slice(src.indexOf('func corsHeaders'), src.indexOf('func corsHeaders') + 500);
340 assert.ok(corsBlock.includes('"*"'), 'must have wildcard fallback');
341 assert.ok(corsBlock.includes('storage.cors_allowed_origin'), 'must use stored origin when locked');
342 });
343
344 test('admin_set_cors_origin function exists and requires controller', () => {
345 const src = loadMain();
346 assert.ok(src.includes('admin_set_cors_origin'), 'must have admin_set_cors_origin function');
347 const fnBlock = src.slice(src.indexOf('admin_set_cors_origin'));
348 assert.ok(fnBlock.includes('isController'), 'must verify caller is controller');
349 assert.ok(fnBlock.includes('FORBIDDEN'), 'must trap non-controllers');
350 });
351
352 test('StableStorage type includes cors_allowed_origin field', () => {
353 const src = loadMigration();
354 const stableBlock = src.slice(src.lastIndexOf('public type StableStorage'));
355 assert.ok(stableBlock.includes('cors_allowed_origin'), 'StableStorage must have cors_allowed_origin');
356 });
357
358 test('migration preserves gateway_auth_secret and cors_allowed_origin', () => {
359 // Post SEC-KN-4c (T4) the actor hook is identity on StableStorage: it returns
360 // `old` unchanged, so every field — including gateway_auth_secret and
361 // cors_allowed_origin — is preserved by construction. Assert that shape plus
362 // the presence of both fields on StableStorage itself.
363 const src = loadMigration();
364 const migBlock = src.slice(src.indexOf('public func migration'));
365 assert.match(
366 migBlock,
367 /public func migration\(old : \{ var storage : StableStorage \}\) : \{ var storage : StableStorage \}/,
368 'migration must be identity on StableStorage (preserves all fields)',
369 );
370 assert.ok(/\{\s*old\s*;?\s*\}/.test(migBlock) || migBlock.includes('old\n'),
371 'migration body must return old unchanged');
372 const stableBlock = src.slice(src.lastIndexOf('public type StableStorage'));
373 assert.ok(stableBlock.includes('gateway_auth_secret'),
374 'StableStorage must carry gateway_auth_secret through upgrades');
375 assert.ok(stableBlock.includes('cors_allowed_origin'),
376 'StableStorage must carry cors_allowed_origin through upgrades');
377 });
378
379 test('saveStable preserves cors_allowed_origin', () => {
380 const src = loadMain();
381 const saveBlock = src.slice(src.indexOf('func saveStable'));
382 assert.ok(saveBlock.includes('keepCorsOrigin'), 'saveStable must preserve cors origin');
383 assert.ok(saveBlock.includes('cors_allowed_origin = keepCorsOrigin'), 'saveStable must write cors origin');
384 });
385 });
386
387 // ---------------------------------------------------------------------------
388 // 3.6 path-to-regexp ReDoS CVE resolved
389 // ---------------------------------------------------------------------------
390 // ---------------------------------------------------------------------------
391 // Bridge → canister X-Gateway-Auth header (Phase 0 compatibility fix)
392 // ---------------------------------------------------------------------------
393 describe('Bridge canister calls include X-Gateway-Auth header', () => {
394 let bridgeSrc;
395 const load = () => {
396 if (!bridgeSrc) bridgeSrc = fs.readFileSync(path.join(ROOT, 'hub/bridge/server.mjs'), 'utf8');
397 return bridgeSrc;
398 };
399
400 test('bridge reads CANISTER_AUTH_SECRET from env (same var name as gateway)', () => {
401 const src = load();
402 assert.ok(src.includes('CANISTER_AUTH_SECRET'), 'bridge must read CANISTER_AUTH_SECRET env var');
403 });
404
405 test('bridge has canisterHeaders() helper that injects x-gateway-auth', () => {
406 const src = load();
407 assert.ok(src.includes('function canisterHeaders'), 'bridge must define canisterHeaders helper');
408 assert.ok(src.includes("'x-gateway-auth'"), 'canisterHeaders must set x-gateway-auth header');
409 });
410
411 test('canisterHeaders() is used at every canister fetch call site', () => {
412 const src = load();
413 // Count how many times we call fetch on the canister URL (CANISTER_URL or ${base}/api)
414 const fetchCanisterCount = (src.match(/fetch\(CANISTER_URL|fetch\(`\$\{CANISTER_URL\}|fetch\(`\$\{base\}/g) || []).length;
415 // Count how many times canisterHeaders appears near those calls
416 const canisterHeadersCount = (src.match(/canisterHeaders\(/g) || []).length;
417 assert.ok(
418 canisterHeadersCount >= fetchCanisterCount,
419 `canisterHeaders() must appear at least as many times as canister fetch calls (fetches: ${fetchCanisterCount}, canisterHeaders: ${canisterHeadersCount})`,
420 );
421 });
422 });
423
424 describe('3.6 path-to-regexp ReDoS CVE resolved', () => {
425 test('hub/package-lock.json has path-to-regexp >= 0.1.13', () => {
426 const lockPath = path.join(ROOT, 'hub/package-lock.json');
427 if (!fs.existsSync(lockPath)) return;
428 const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
429 const packages = lock.packages || {};
430 for (const [pkg, info] of Object.entries(packages)) {
431 if (pkg.endsWith('/path-to-regexp') || pkg === 'path-to-regexp') {
432 const ver = info.version;
433 if (ver && ver.startsWith('0.1.')) {
434 const patch = parseInt(ver.split('.')[2], 10);
435 assert.ok(patch >= 13, `path-to-regexp must be >= 0.1.13 (found ${ver})`);
436 }
437 }
438 }
439 });
440
441 test('hub/gateway/package-lock.json has path-to-regexp >= 0.1.13', () => {
442 const lockPath = path.join(ROOT, 'hub/gateway/package-lock.json');
443 if (!fs.existsSync(lockPath)) return;
444 const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
445 const packages = lock.packages || {};
446 for (const [pkg, info] of Object.entries(packages)) {
447 if (pkg.endsWith('/path-to-regexp') || pkg === 'path-to-regexp') {
448 const ver = info.version;
449 if (ver && ver.startsWith('0.1.')) {
450 const patch = parseInt(ver.split('.')[2], 10);
451 assert.ok(patch >= 13, `path-to-regexp must be >= 0.1.13 (found ${ver})`);
452 }
453 }
454 }
455 });
456
457 test('root package-lock.json has path-to-regexp >= 0.1.13', () => {
458 const lockPath = path.join(ROOT, 'package-lock.json');
459 if (!fs.existsSync(lockPath)) return;
460 const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
461 const packages = lock.packages || {};
462 for (const [pkg, info] of Object.entries(packages)) {
463 if (pkg.endsWith('/path-to-regexp') || pkg === 'path-to-regexp') {
464 const ver = info.version;
465 if (ver && ver.startsWith('0.1.')) {
466 const patch = parseInt(ver.split('.')[2], 10);
467 assert.ok(patch >= 13, `path-to-regexp must be >= 0.1.13 (found ${ver})`);
468 }
469 }
470 }
471 });
472 });
473
474 // ---------------------------------------------------------------------------
475 // 3.7 Muse thin bridge (Option C): operator proxy route markers
476 // ---------------------------------------------------------------------------
477 describe('3.7 Muse thin bridge: operator proxy present on gateway and Node Hub', () => {
478 test('gateway registers GET /api/v1/operator/muse/proxy with requireAdmin', () => {
479 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
480 assert.ok(
481 src.includes('/api/v1/operator/muse/proxy'),
482 'gateway must expose operator Muse proxy path',
483 );
484 assert.ok(
485 src.includes('fetchMuseProxiedGet') && src.includes('parseMuseConfigFromEnv'),
486 'gateway must use muse-thin-bridge helpers for proxy',
487 );
488 });
489
490 test('self-hosted Hub registers GET /api/v1/operator/muse/proxy with jwtAuth and admin role', () => {
491 const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8');
492 assert.ok(
493 src.includes('/api/v1/operator/muse/proxy'),
494 'Node Hub must expose operator Muse proxy path',
495 );
496 assert.ok(
497 src.includes('fetchMuseProxiedGet') && src.includes("requireRole('admin')"),
498 'Node Hub must gate Muse proxy with admin role',
499 );
500 });
501
502 test('Node Hub exposes POST /api/v1/settings/muse for self-hosted YAML Muse URL', () => {
503 const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8');
504 assert.ok(
505 src.includes("'/api/v1/settings/muse'"),
506 'Node Hub must allow admins to persist muse.url in config/local.yaml',
507 );
508 });
509
510 test('gateway rejects POST /api/v1/settings/muse (hosted operator-only)', () => {
511 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
512 assert.ok(
513 src.includes("'/api/v1/settings/muse'") && src.includes('501'),
514 'gateway must not allow browser clients to set Muse URL',
515 );
516 });
517 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago