phase3-security.test.mjs
519 lines 24.3 KB
Raw
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2 feat(auth): persistent login system + C7 session introspection Human minor ⚠ breaking 16 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 in-memory refresh token store: periodic sweep for expired entries
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 in-memory refresh token store: periodic sweep
264 // ---------------------------------------------------------------------------
265 describe('3.4 MCP refresh token store — periodic expired-token sweep', () => {
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 has _sweepExpiredRefreshTokens method', () => {
273 const src = load();
274 assert.ok(src.includes('_sweepExpiredRefreshTokens'), 'must have sweep method');
275 });
276
277 test('constructor sets up periodic sweep timer', () => {
278 const src = load();
279 assert.ok(src.includes('setInterval'), 'constructor must create setInterval for sweep');
280 assert.ok(src.includes('REFRESH_SWEEP_INTERVAL_MS'), 'must use configured interval constant');
281 });
282
283 test('sweep timer is unref()d to not block Node process exit', () => {
284 const src = load();
285 assert.ok(src.includes('.unref'), 'sweep timer must call unref() to not block exit');
286 });
287
288 test('sweep method deletes expired refresh tokens', () => {
289 const src = load();
290 const sweepBlock = src.slice(src.indexOf('_sweepExpiredRefreshTokens'));
291 assert.ok(sweepBlock.includes('_refreshTokens.delete'), 'sweep must delete expired tokens');
292 assert.ok(sweepBlock.includes('expires'), 'sweep must check expiry');
293 });
294
295 test('destroy() method clears the sweep timer', () => {
296 const src = load();
297 assert.ok(src.includes('destroy()'), 'must have destroy method');
298 assert.ok(src.includes('clearInterval'), 'destroy must clear the interval');
299 });
300
301 test('sweep interval is reasonable (5–30 minutes)', () => {
302 const src = load();
303 const match = src.match(/REFRESH_SWEEP_INTERVAL_MS\s*=\s*([^;]+)/);
304 assert.ok(match, 'REFRESH_SWEEP_INTERVAL_MS must be defined');
305 const ms = Function(`return ${match[1].trim()}`)();
306 assert.ok(ms >= 5 * 60 * 1000 && ms <= 30 * 60 * 1000,
307 `sweep interval must be 5–30 min, got ${ms / 60000} min`);
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 const src = loadMigration();
373 const migBlock = src.slice(src.indexOf('public func migration'));
374 assert.ok(migBlock.includes('gateway_auth_secret = old.storage.gateway_auth_secret'),
375 'migration must preserve existing gateway auth secret');
376 // V7 stable already includes cors_allowed_origin; actor hook maps V7→current by preserving it.
377 assert.ok(migBlock.includes('cors_allowed_origin = old.storage.cors_allowed_origin'),
378 'migration must preserve cors_allowed_origin from V7 storage');
379 });
380
381 test('saveStable preserves cors_allowed_origin', () => {
382 const src = loadMain();
383 const saveBlock = src.slice(src.indexOf('func saveStable'));
384 assert.ok(saveBlock.includes('keepCorsOrigin'), 'saveStable must preserve cors origin');
385 assert.ok(saveBlock.includes('cors_allowed_origin = keepCorsOrigin'), 'saveStable must write cors origin');
386 });
387 });
388
389 // ---------------------------------------------------------------------------
390 // 3.6 path-to-regexp ReDoS CVE resolved
391 // ---------------------------------------------------------------------------
392 // ---------------------------------------------------------------------------
393 // Bridge → canister X-Gateway-Auth header (Phase 0 compatibility fix)
394 // ---------------------------------------------------------------------------
395 describe('Bridge canister calls include X-Gateway-Auth header', () => {
396 let bridgeSrc;
397 const load = () => {
398 if (!bridgeSrc) bridgeSrc = fs.readFileSync(path.join(ROOT, 'hub/bridge/server.mjs'), 'utf8');
399 return bridgeSrc;
400 };
401
402 test('bridge reads CANISTER_AUTH_SECRET from env (same var name as gateway)', () => {
403 const src = load();
404 assert.ok(src.includes('CANISTER_AUTH_SECRET'), 'bridge must read CANISTER_AUTH_SECRET env var');
405 });
406
407 test('bridge has canisterHeaders() helper that injects x-gateway-auth', () => {
408 const src = load();
409 assert.ok(src.includes('function canisterHeaders'), 'bridge must define canisterHeaders helper');
410 assert.ok(src.includes("'x-gateway-auth'"), 'canisterHeaders must set x-gateway-auth header');
411 });
412
413 test('canisterHeaders() is used at every canister fetch call site', () => {
414 const src = load();
415 // Count how many times we call fetch on the canister URL (CANISTER_URL or ${base}/api)
416 const fetchCanisterCount = (src.match(/fetch\(CANISTER_URL|fetch\(`\$\{CANISTER_URL\}|fetch\(`\$\{base\}/g) || []).length;
417 // Count how many times canisterHeaders appears near those calls
418 const canisterHeadersCount = (src.match(/canisterHeaders\(/g) || []).length;
419 assert.ok(
420 canisterHeadersCount >= fetchCanisterCount,
421 `canisterHeaders() must appear at least as many times as canister fetch calls (fetches: ${fetchCanisterCount}, canisterHeaders: ${canisterHeadersCount})`,
422 );
423 });
424 });
425
426 describe('3.6 path-to-regexp ReDoS CVE resolved', () => {
427 test('hub/package-lock.json has path-to-regexp >= 0.1.13', () => {
428 const lockPath = path.join(ROOT, 'hub/package-lock.json');
429 if (!fs.existsSync(lockPath)) return;
430 const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
431 const packages = lock.packages || {};
432 for (const [pkg, info] of Object.entries(packages)) {
433 if (pkg.endsWith('/path-to-regexp') || pkg === 'path-to-regexp') {
434 const ver = info.version;
435 if (ver && ver.startsWith('0.1.')) {
436 const patch = parseInt(ver.split('.')[2], 10);
437 assert.ok(patch >= 13, `path-to-regexp must be >= 0.1.13 (found ${ver})`);
438 }
439 }
440 }
441 });
442
443 test('hub/gateway/package-lock.json has path-to-regexp >= 0.1.13', () => {
444 const lockPath = path.join(ROOT, 'hub/gateway/package-lock.json');
445 if (!fs.existsSync(lockPath)) return;
446 const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
447 const packages = lock.packages || {};
448 for (const [pkg, info] of Object.entries(packages)) {
449 if (pkg.endsWith('/path-to-regexp') || pkg === 'path-to-regexp') {
450 const ver = info.version;
451 if (ver && ver.startsWith('0.1.')) {
452 const patch = parseInt(ver.split('.')[2], 10);
453 assert.ok(patch >= 13, `path-to-regexp must be >= 0.1.13 (found ${ver})`);
454 }
455 }
456 }
457 });
458
459 test('root package-lock.json has path-to-regexp >= 0.1.13', () => {
460 const lockPath = path.join(ROOT, 'package-lock.json');
461 if (!fs.existsSync(lockPath)) return;
462 const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
463 const packages = lock.packages || {};
464 for (const [pkg, info] of Object.entries(packages)) {
465 if (pkg.endsWith('/path-to-regexp') || pkg === 'path-to-regexp') {
466 const ver = info.version;
467 if (ver && ver.startsWith('0.1.')) {
468 const patch = parseInt(ver.split('.')[2], 10);
469 assert.ok(patch >= 13, `path-to-regexp must be >= 0.1.13 (found ${ver})`);
470 }
471 }
472 }
473 });
474 });
475
476 // ---------------------------------------------------------------------------
477 // 3.7 Muse thin bridge (Option C): operator proxy route markers
478 // ---------------------------------------------------------------------------
479 describe('3.7 Muse thin bridge: operator proxy present on gateway and Node Hub', () => {
480 test('gateway registers GET /api/v1/operator/muse/proxy with requireAdmin', () => {
481 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
482 assert.ok(
483 src.includes('/api/v1/operator/muse/proxy'),
484 'gateway must expose operator Muse proxy path',
485 );
486 assert.ok(
487 src.includes('fetchMuseProxiedGet') && src.includes('parseMuseConfigFromEnv'),
488 'gateway must use muse-thin-bridge helpers for proxy',
489 );
490 });
491
492 test('self-hosted Hub registers GET /api/v1/operator/muse/proxy with jwtAuth and admin role', () => {
493 const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8');
494 assert.ok(
495 src.includes('/api/v1/operator/muse/proxy'),
496 'Node Hub must expose operator Muse proxy path',
497 );
498 assert.ok(
499 src.includes('fetchMuseProxiedGet') && src.includes("requireRole('admin')"),
500 'Node Hub must gate Muse proxy with admin role',
501 );
502 });
503
504 test('Node Hub exposes POST /api/v1/settings/muse for self-hosted YAML Muse URL', () => {
505 const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8');
506 assert.ok(
507 src.includes("'/api/v1/settings/muse'"),
508 'Node Hub must allow admins to persist muse.url in config/local.yaml',
509 );
510 });
511
512 test('gateway rejects POST /api/v1/settings/muse (hosted operator-only)', () => {
513 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
514 assert.ok(
515 src.includes("'/api/v1/settings/muse'") && src.includes('501'),
516 'gateway must not allow browser clients to set Muse URL',
517 );
518 });
519 });
File History 2 commits
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2 feat(auth): persistent login system + C7 session introspection Human minor 16 days ago