gateway-settings-hosted-vault-filter.test.mjs
sha256:6f47d53a6adbcf105ba1b9cfc126c788d6a0f461d197f84f78794914305b4bd5
fix(mcp): bound hosted discovery context
Human
patch
6 days ago
| 1 | /** |
| 2 | * Hosted Hub settings vault filter: |
| 3 | * a delegated evaluator with explicit Business access must not see owner/default |
| 4 | * canister vaults in the switcher. |
| 5 | */ |
| 6 | |
| 7 | import { test } from 'node:test'; |
| 8 | import assert from 'node:assert/strict'; |
| 9 | import http from 'node:http'; |
| 10 | import crypto from 'node:crypto'; |
| 11 | import path from 'node:path'; |
| 12 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 13 | |
| 14 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 15 | const projectRoot = path.resolve(__dirname, '..'); |
| 16 | const SECRET = 'gateway-settings-hosted-vault-filter-secret-32'; |
| 17 | |
| 18 | function signTestJwt(payload) { |
| 19 | const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); |
| 20 | const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); |
| 21 | const data = `${header}.${body}`; |
| 22 | const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url'); |
| 23 | return `${data}.${sig}`; |
| 24 | } |
| 25 | |
| 26 | test('gateway settings filters delegated evaluator switcher to bridge-allowed Business vault', async (t) => { |
| 27 | const origFetch = globalThis.fetch.bind(globalThis); |
| 28 | t.after(() => { |
| 29 | globalThis.fetch = origFetch; |
| 30 | }); |
| 31 | |
| 32 | /** @type {Array<{ url: string, method: string, headers?: HeadersInit }>} */ |
| 33 | const calls = []; |
| 34 | globalThis.fetch = async (url, init = {}) => { |
| 35 | const u = String(url); |
| 36 | const method = init.method || 'GET'; |
| 37 | calls.push({ url: u, method, headers: init.headers }); |
| 38 | |
| 39 | if (u === 'https://mock-bridge.test/api/v1/hosted-context/settings') { |
| 40 | return { |
| 41 | ok: true, |
| 42 | status: 200, |
| 43 | json: async () => ({ |
| 44 | actor_sub: 'google:user456', |
| 45 | workspace_owner_id: 'google:owner', |
| 46 | effective_canister_user_id: 'google:owner', |
| 47 | delegating: true, |
| 48 | allowed_vault_ids: ['Business'], |
| 49 | role: 'evaluator', |
| 50 | may_approve_proposals: true, |
| 51 | }), |
| 52 | }; |
| 53 | } |
| 54 | if (u === 'https://mock-bridge.test/api/v1/hosted-context') { |
| 55 | throw new Error('settings must not request default hosted-context'); |
| 56 | } |
| 57 | if (u === 'https://mock-canister.test/api/v1/vaults') { |
| 58 | return { |
| 59 | ok: true, |
| 60 | status: 200, |
| 61 | json: async () => ({ |
| 62 | vaults: [ |
| 63 | { id: 'Personal', label: 'Personal' }, |
| 64 | { id: 'Secrets', label: 'Secrets' }, |
| 65 | { id: 'Ideas', label: 'Ideas' }, |
| 66 | { id: 'default', label: 'Default' }, |
| 67 | { id: 'Business', label: 'Business' }, |
| 68 | ], |
| 69 | }), |
| 70 | }; |
| 71 | } |
| 72 | if (u === 'https://mock-bridge.test/api/v1/vault/github-status') { |
| 73 | return { ok: true, status: 200, json: async () => ({ github_connected: false, repo: null }) }; |
| 74 | } |
| 75 | if (u === 'https://mock-bridge.test/api/v1/role') { |
| 76 | return { ok: true, status: 200, json: async () => ({ role: 'evaluator', may_approve_proposals: true }) }; |
| 77 | } |
| 78 | return { ok: false, status: 404, json: async () => ({ error: 'unexpected ' + u }), text: async () => '' }; |
| 79 | }; |
| 80 | |
| 81 | process.env.NETLIFY = '1'; |
| 82 | process.env.CANISTER_URL = 'https://mock-canister.test'; |
| 83 | process.env.SESSION_SECRET = SECRET; |
| 84 | process.env.BRIDGE_URL = 'https://mock-bridge.test'; |
| 85 | delete process.env.BILLING_ENFORCE; |
| 86 | delete process.env.KNOWTATION_AIR_ENDPOINT; |
| 87 | |
| 88 | const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href; |
| 89 | const { app } = await import(`${gwEntry}?settingsvaultfilter=${Date.now()}`); |
| 90 | const srv = http.createServer(app); |
| 91 | await new Promise((resolve, reject) => { |
| 92 | srv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve())); |
| 93 | }); |
| 94 | t.after(() => new Promise((resolve) => srv.close(() => resolve()))); |
| 95 | |
| 96 | const port = srv.address().port; |
| 97 | const token = signTestJwt({ sub: 'google:user456', role: 'evaluator' }); |
| 98 | const res = await origFetch(`http://127.0.0.1:${port}/api/v1/settings`, { |
| 99 | headers: { Authorization: `Bearer ${token}`, 'X-Vault-Id': 'Business' }, |
| 100 | }); |
| 101 | const text = await res.text(); |
| 102 | assert.equal(res.status, 200, text); |
| 103 | const body = JSON.parse(text); |
| 104 | |
| 105 | assert.deepEqual(body.allowed_vault_ids, ['Business']); |
| 106 | assert.deepEqual(body.vault_list, [{ id: 'Business', label: 'Business' }]); |
| 107 | assert.equal(body.workspace_owner_id, 'google:owner'); |
| 108 | assert.equal(body.hosted_delegating, true); |
| 109 | assert.equal(body.role, 'evaluator'); |
| 110 | |
| 111 | const hostedContextCalls = calls.filter((c) => c.url.includes('/api/v1/hosted-context')); |
| 112 | assert.deepEqual( |
| 113 | hostedContextCalls.map((c) => c.url), |
| 114 | ['https://mock-bridge.test/api/v1/hosted-context/settings'], |
| 115 | ); |
| 116 | const vaultCall = calls.find((c) => c.url === 'https://mock-canister.test/api/v1/vaults'); |
| 117 | assert.ok(vaultCall, 'settings must still read canister vault labels'); |
| 118 | assert.match(String(vaultCall.headers?.['X-User-Id'] || vaultCall.headers?.['x-user-id']), /google:owner/); |
| 119 | }); |
File History
1 commit
sha256:6f47d53a6adbcf105ba1b9cfc126c788d6a0f461d197f84f78794914305b4bd5
fix(mcp): bound hosted discovery context
Human
patch
6 days ago