gateway-muse-proxy-audit.test.mjs
175 lines 7.7 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Gateway: operator Muse proxy — auth, 404 when disabled, path validation.
3 */
4 import { test } from 'node:test';
5 import assert from 'node:assert/strict';
6 import http from 'http';
7 import path from 'path';
8 import { fileURLToPath, pathToFileURL } from 'url';
9 import crypto from 'crypto';
10
11 const __dirname = path.dirname(fileURLToPath(import.meta.url));
12 const projectRoot = path.resolve(__dirname, '..');
13
14 const SECRET = 'gateway-muse-proxy-audit-secret-32chars!!';
15
16 function signTestJwt(payload) {
17 const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
18 const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
19 const data = `${header}.${body}`;
20 const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
21 return `${data}.${sig}`;
22 }
23
24 test('GET operator/muse/proxy returns 404 when MUSE_URL unset (no auth required for this branch)', async (t) => {
25 delete process.env.MUSE_URL;
26 process.env.NETLIFY = '1';
27 process.env.CANISTER_URL = 'http://127.0.0.1:9';
28 process.env.SESSION_SECRET = SECRET;
29
30 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
31 const { app: gwApp } = await import(`${gwEntry}?gwMuseProxyOff=${Date.now()}`);
32
33 const srv = http.createServer(gwApp);
34 await new Promise((resolve, reject) => srv.listen(0, '127.0.0.1', (e) => (e ? reject(e) : resolve())));
35 t.after(() => new Promise((r) => srv.close(() => r())));
36 const port = /** @type {import('net').AddressInfo} */ (srv.address()).port;
37
38 const res = await fetch(`http://127.0.0.1:${port}/api/v1/operator/muse/proxy?path=${encodeURIComponent('/knowtation/v1/x')}`);
39 assert.strictEqual(res.status, 404);
40 const j = await res.json();
41 assert.strictEqual(j.code, 'NOT_FOUND');
42 });
43
44 test('GET operator/muse/proxy returns 401 without JWT when MUSE_URL set', async (t) => {
45 process.env.NETLIFY = '1';
46 process.env.CANISTER_URL = 'http://127.0.0.1:9';
47 process.env.SESSION_SECRET = SECRET;
48 process.env.MUSE_URL = 'https://muse-operator.example.com';
49 delete process.env.BRIDGE_URL;
50
51 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
52 const { app: gwApp } = await import(`${gwEntry}?gwMuseProxy401=${Date.now()}`);
53
54 const srv = http.createServer(gwApp);
55 await new Promise((resolve, reject) => srv.listen(0, '127.0.0.1', (e) => (e ? reject(e) : resolve())));
56 t.after(() => new Promise((r) => srv.close(() => r())));
57 const port = /** @type {import('net').AddressInfo} */ (srv.address()).port;
58
59 const res = await fetch(
60 `http://127.0.0.1:${port}/api/v1/operator/muse/proxy?path=${encodeURIComponent('/knowtation/v1/x')}`,
61 );
62 assert.strictEqual(res.status, 401);
63 });
64
65 test('GET operator/muse/proxy returns 403 for non-admin when MUSE_URL set', async (t) => {
66 process.env.NETLIFY = '1';
67 process.env.CANISTER_URL = 'http://127.0.0.1:9';
68 process.env.SESSION_SECRET = SECRET;
69 process.env.MUSE_URL = 'https://muse-operator.example.com';
70 process.env.HUB_ADMIN_USER_IDS = 'google:only-admin-here';
71 delete process.env.BRIDGE_URL;
72
73 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
74 const { app: gwApp } = await import(`${gwEntry}?gwMuseProxy403=${Date.now()}`);
75
76 const srv = http.createServer(gwApp);
77 await new Promise((resolve, reject) => srv.listen(0, '127.0.0.1', (e) => (e ? reject(e) : resolve())));
78 t.after(() => new Promise((r) => srv.close(() => r())));
79 const port = /** @type {import('net').AddressInfo} */ (srv.address()).port;
80
81 const token = signTestJwt({ sub: 'google:regular-member-not-admin' });
82 const res = await fetch(
83 `http://127.0.0.1:${port}/api/v1/operator/muse/proxy?path=${encodeURIComponent('/knowtation/v1/x')}`,
84 { headers: { Authorization: `Bearer ${token}` } },
85 );
86 assert.strictEqual(res.status, 403);
87 });
88
89 test('GET operator/muse/proxy returns 400 when path missing', async (t) => {
90 process.env.NETLIFY = '1';
91 process.env.CANISTER_URL = 'http://127.0.0.1:9';
92 process.env.SESSION_SECRET = SECRET;
93 process.env.MUSE_URL = 'https://muse-operator.example.com';
94 process.env.HUB_ADMIN_USER_IDS = 'google:muse-proxy-admin';
95
96 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
97 const { app: gwApp } = await import(`${gwEntry}?gwMuseProxy400=${Date.now()}`);
98
99 const srv = http.createServer(gwApp);
100 await new Promise((resolve, reject) => srv.listen(0, '127.0.0.1', (e) => (e ? reject(e) : resolve())));
101 t.after(() => new Promise((r) => srv.close(() => r())));
102 const port = /** @type {import('net').AddressInfo} */ (srv.address()).port;
103
104 const token = signTestJwt({ sub: 'google:muse-proxy-admin' });
105 const res = await fetch(`http://127.0.0.1:${port}/api/v1/operator/muse/proxy`, {
106 headers: { Authorization: `Bearer ${token}` },
107 });
108 assert.strictEqual(res.status, 400);
109 });
110
111 test('GET operator/muse/proxy returns 200 and upstream body for admin when path allowed', async (t) => {
112 const mockMuse = http.createServer((req, res) => {
113 if (req.url && req.url.startsWith('/knowtation/v1/hello')) {
114 res.statusCode = 200;
115 res.setHeader('Content-Type', 'application/json');
116 res.end(JSON.stringify({ ok: true, from: 'mock-muse' }));
117 return;
118 }
119 res.statusCode = 404;
120 res.end();
121 });
122 await new Promise((resolve, reject) => {
123 mockMuse.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
124 });
125 t.after(() => new Promise((r) => mockMuse.close(() => r())));
126 const musePort = /** @type {import('net').AddressInfo} */ (mockMuse.address()).port;
127 const museUrl = `http://127.0.0.1:${musePort}`;
128
129 process.env.NETLIFY = '1';
130 process.env.CANISTER_URL = 'http://127.0.0.1:9';
131 process.env.SESSION_SECRET = SECRET;
132 process.env.MUSE_URL = museUrl;
133 process.env.HUB_ADMIN_USER_IDS = 'google:muse-proxy-ok-admin';
134 delete process.env.BRIDGE_URL;
135
136 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
137 const { app: gwApp } = await import(`${gwEntry}?gwMuseProxyOk=${Date.now()}`);
138
139 const srv = http.createServer(gwApp);
140 await new Promise((resolve, reject) => srv.listen(0, '127.0.0.1', (e) => (e ? reject(e) : resolve())));
141 t.after(() => new Promise((r) => srv.close(() => r())));
142 const port = /** @type {import('net').AddressInfo} */ (srv.address()).port;
143
144 const token = signTestJwt({ sub: 'google:muse-proxy-ok-admin' });
145 const pathQ = encodeURIComponent('/knowtation/v1/hello');
146 const res = await fetch(`http://127.0.0.1:${port}/api/v1/operator/muse/proxy?path=${pathQ}`, {
147 headers: { Authorization: `Bearer ${token}` },
148 });
149 assert.strictEqual(res.status, 200);
150 const j = await res.json();
151 assert.strictEqual(j.from, 'mock-muse');
152 });
153
154 test('GET operator/muse/proxy returns 400 for disallowed path prefix', async (t) => {
155 process.env.NETLIFY = '1';
156 process.env.CANISTER_URL = 'http://127.0.0.1:9';
157 process.env.SESSION_SECRET = SECRET;
158 process.env.MUSE_URL = 'https://muse-operator.example.com';
159 process.env.HUB_ADMIN_USER_IDS = 'google:muse-proxy-admin2';
160
161 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
162 const { app: gwApp } = await import(`${gwEntry}?gwMuseProxy400b=${Date.now()}`);
163
164 const srv = http.createServer(gwApp);
165 await new Promise((resolve, reject) => srv.listen(0, '127.0.0.1', (e) => (e ? reject(e) : resolve())));
166 t.after(() => new Promise((r) => srv.close(() => r())));
167 const port = /** @type {import('net').AddressInfo} */ (srv.address()).port;
168
169 const token = signTestJwt({ sub: 'google:muse-proxy-admin2' });
170 const res = await fetch(
171 `http://127.0.0.1:${port}/api/v1/operator/muse/proxy?path=${encodeURIComponent('/etc/passwd')}`,
172 { headers: { Authorization: `Bearer ${token}` } },
173 );
174 assert.strictEqual(res.status, 400);
175 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago