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