gateway-muse-approve-body.test.mjs
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