gateway-muse-approve-body.test.mjs
256 lines 9.8 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Gateway merges resolved external_ref into approve POST body before canister proxy.
3 */
4 import { test } from 'node:test';
5 import assert from 'node:assert/strict';
6 import http from 'http';
7 import { pathToFileURL } from 'url';
8 import path from 'path';
9 import { fileURLToPath } from 'url';
10 import crypto from 'crypto';
11
12 const __dirname = path.dirname(fileURLToPath(import.meta.url));
13 const projectRoot = path.resolve(__dirname, '..');
14
15 const SECRET = 'gateway-muse-approve-test-secret-32chars';
16
17 function signTestJwt(payload) {
18 const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
19 const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
20 const data = `${header}.${body}`;
21 const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
22 return `${data}.${sig}`;
23 }
24
25 test('gateway POST proposals/:id/approve forwards external_ref from Muse lineage when MUSE_URL set', async (t) => {
26 /** @type {string | null} */
27 let capturedBody = null;
28
29 const mockMuse = http.createServer((req, res) => {
30 if (!req.url || !req.url.startsWith('/knowtation/v1/lineage-ref')) {
31 res.statusCode = 404;
32 res.end();
33 return;
34 }
35 res.setHeader('Content-Type', 'application/json');
36 res.end(JSON.stringify({ external_ref: 'ref-from-muse-lineage' }));
37 });
38 await new Promise((resolve, reject) => {
39 mockMuse.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
40 });
41 t.after(() => new Promise((r) => mockMuse.close(() => r())));
42 const musePort = /** @type {import('net').AddressInfo} */ (mockMuse.address()).port;
43 const museUrl = `http://127.0.0.1:${musePort}`;
44
45 const mockCanister = http.createServer((req, res) => {
46 if (req.method === 'POST' && req.url.startsWith('/api/v1/proposals/prop-muse/approve')) {
47 const chunks = [];
48 req.on('data', (c) => chunks.push(c));
49 req.on('end', () => {
50 capturedBody = Buffer.concat(chunks).toString('utf8');
51 res.setHeader('Content-Type', 'application/json');
52 res.end(
53 JSON.stringify({
54 proposal_id: 'prop-muse',
55 status: 'approved',
56 approval_log_path: 'approvals/x.md',
57 approval_log_written: true,
58 external_ref: 'ref-from-muse-lineage',
59 }),
60 );
61 });
62 return;
63 }
64 res.statusCode = 404;
65 res.end('{}');
66 });
67 await new Promise((resolve, reject) => {
68 mockCanister.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
69 });
70 t.after(() => new Promise((r) => mockCanister.close(() => r())));
71 const canisterPort = /** @type {import('net').AddressInfo} */ (mockCanister.address()).port;
72 const canisterUrl = `http://127.0.0.1:${canisterPort}`;
73
74 process.env.NETLIFY = '1';
75 process.env.CANISTER_URL = canisterUrl;
76 process.env.SESSION_SECRET = SECRET;
77 process.env.MUSE_URL = museUrl;
78 process.env.HUB_ADMIN_USER_IDS = 'google:gw-muse-test';
79 delete process.env.BRIDGE_URL;
80 delete process.env.MUSE_API_KEY;
81
82 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
83 const { app: gwApp } = await import(`${gwEntry}?gwmuse=${Date.now()}`);
84
85 const gwSrv = http.createServer(gwApp);
86 await new Promise((resolve, reject) => {
87 gwSrv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
88 });
89 t.after(() => new Promise((r) => gwSrv.close(() => r())));
90 const gwPort = /** @type {import('net').AddressInfo} */ (gwSrv.address()).port;
91
92 const token = signTestJwt({ sub: 'google:gw-muse-test' });
93 const res = await fetch(`http://127.0.0.1:${gwPort}/api/v1/proposals/prop-muse/approve`, {
94 method: 'POST',
95 headers: {
96 Authorization: `Bearer ${token}`,
97 'Content-Type': 'application/json',
98 'X-Vault-Id': 'default',
99 },
100 body: JSON.stringify({}),
101 });
102
103 assert.strictEqual(res.status, 200, await res.text());
104 assert.ok(capturedBody);
105 const parsed = JSON.parse(/** @type {string} */ (capturedBody));
106 assert.strictEqual(parsed.external_ref, 'ref-from-muse-lineage');
107 });
108
109 test('gateway POST proposals/:id/approve still proxies when Muse lineage fails', async (t) => {
110 let capturedBody = null;
111
112 const mockMuse = http.createServer((_req, res) => {
113 res.statusCode = 503;
114 res.end('unavailable');
115 });
116 await new Promise((resolve, reject) => {
117 mockMuse.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
118 });
119 t.after(() => new Promise((r) => mockMuse.close(() => r())));
120 const musePort = /** @type {import('net').AddressInfo} */ (mockMuse.address()).port;
121 const museUrl = `http://127.0.0.1:${musePort}`;
122
123 const mockCanister = http.createServer((req, res) => {
124 if (req.method === 'POST' && req.url.startsWith('/api/v1/proposals/prop-down/approve')) {
125 const chunks = [];
126 req.on('data', (c) => chunks.push(c));
127 req.on('end', () => {
128 capturedBody = Buffer.concat(chunks).toString('utf8');
129 res.setHeader('Content-Type', 'application/json');
130 res.end(
131 JSON.stringify({
132 proposal_id: 'prop-down',
133 status: 'approved',
134 approval_log_path: 'approvals/y.md',
135 approval_log_written: true,
136 external_ref: '',
137 }),
138 );
139 });
140 return;
141 }
142 res.statusCode = 404;
143 res.end('{}');
144 });
145 await new Promise((resolve, reject) => {
146 mockCanister.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
147 });
148 t.after(() => new Promise((r) => mockCanister.close(() => r())));
149 const canisterPort = /** @type {import('net').AddressInfo} */ (mockCanister.address()).port;
150 const canisterUrl = `http://127.0.0.1:${canisterPort}`;
151
152 process.env.NETLIFY = '1';
153 process.env.CANISTER_URL = canisterUrl;
154 process.env.SESSION_SECRET = SECRET;
155 process.env.MUSE_URL = museUrl;
156 process.env.HUB_ADMIN_USER_IDS = 'google:gw-muse-test2';
157 delete process.env.BRIDGE_URL;
158
159 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
160 const { app: gwApp } = await import(`${gwEntry}?gwmuse2=${Date.now()}`);
161
162 const gwSrv = http.createServer(gwApp);
163 await new Promise((resolve, reject) => {
164 gwSrv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
165 });
166 t.after(() => new Promise((r) => gwSrv.close(() => r())));
167 const gwPort = /** @type {import('net').AddressInfo} */ (gwSrv.address()).port;
168
169 const token = signTestJwt({ sub: 'google:gw-muse-test2' });
170 const res = await fetch(`http://127.0.0.1:${gwPort}/api/v1/proposals/prop-down/approve`, {
171 method: 'POST',
172 headers: {
173 Authorization: `Bearer ${token}`,
174 'Content-Type': 'application/json',
175 'X-Vault-Id': 'default',
176 },
177 body: JSON.stringify({}),
178 });
179
180 assert.strictEqual(res.status, 200, await res.text());
181 assert.ok(capturedBody);
182 const parsed = JSON.parse(/** @type {string} */ (capturedBody));
183 assert.strictEqual(parsed.external_ref ?? '', '');
184 });
185
186 test('gateway POST proposals/:id/approve keeps client external_ref when Muse would return a different ref', async (t) => {
187 let museHits = 0;
188 const mockMuse = http.createServer((req, res) => {
189 museHits += 1;
190 res.setHeader('Content-Type', 'application/json');
191 res.end(JSON.stringify({ external_ref: 'from-muse-server' }));
192 });
193 await new Promise((resolve, reject) => {
194 mockMuse.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
195 });
196 t.after(() => new Promise((r) => mockMuse.close(() => r())));
197 const musePort = /** @type {import('net').AddressInfo} */ (mockMuse.address()).port;
198 const museUrl = `http://127.0.0.1:${musePort}`;
199
200 /** @type {string | null} */
201 let capturedBody = null;
202 const mockCanister = http.createServer((req, res) => {
203 if (req.method === 'POST' && req.url.startsWith('/api/v1/proposals/prop-client-wins/approve')) {
204 const chunks = [];
205 req.on('data', (c) => chunks.push(c));
206 req.on('end', () => {
207 capturedBody = Buffer.concat(chunks).toString('utf8');
208 res.setHeader('Content-Type', 'application/json');
209 res.end(JSON.stringify({ proposal_id: 'prop-client-wins', status: 'approved' }));
210 });
211 return;
212 }
213 res.statusCode = 404;
214 res.end('{}');
215 });
216 await new Promise((resolve, reject) => {
217 mockCanister.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
218 });
219 t.after(() => new Promise((r) => mockCanister.close(() => r())));
220 const canisterPort = /** @type {import('net').AddressInfo} */ (mockCanister.address()).port;
221 const canisterUrl = `http://127.0.0.1:${canisterPort}`;
222
223 process.env.NETLIFY = '1';
224 process.env.CANISTER_URL = canisterUrl;
225 process.env.SESSION_SECRET = SECRET;
226 process.env.MUSE_URL = museUrl;
227 process.env.HUB_ADMIN_USER_IDS = 'google:gw-muse-client-wins';
228 delete process.env.BRIDGE_URL;
229
230 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
231 const { app: gwApp } = await import(`${gwEntry}?gwmuseClient=${Date.now()}`);
232
233 const gwSrv = http.createServer(gwApp);
234 await new Promise((resolve, reject) => {
235 gwSrv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
236 });
237 t.after(() => new Promise((r) => gwSrv.close(() => r())));
238 const gwPort = /** @type {import('net').AddressInfo} */ (gwSrv.address()).port;
239
240 const token = signTestJwt({ sub: 'google:gw-muse-client-wins' });
241 const res = await fetch(`http://127.0.0.1:${gwPort}/api/v1/proposals/prop-client-wins/approve`, {
242 method: 'POST',
243 headers: {
244 Authorization: `Bearer ${token}`,
245 'Content-Type': 'application/json',
246 'X-Vault-Id': 'default',
247 },
248 body: JSON.stringify({ external_ref: 'client-chosen-ref' }),
249 });
250
251 assert.strictEqual(res.status, 200, await res.text());
252 assert.ok(capturedBody);
253 const parsed = JSON.parse(/** @type {string} */ (capturedBody));
254 assert.strictEqual(parsed.external_ref, 'client-chosen-ref');
255 assert.strictEqual(museHits, 0, 'Muse lineage must not be called when client supplies external_ref');
256 });
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 2 days ago