gateway-settings-hosted-vault-filter.test.mjs
119 lines 4.7 KB
Raw
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