gateway-metadata-facets-rest.test.mjs
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | /** |
| 2 | * Gateway REST MetadataFacets tests. |
| 3 | * |
| 4 | * The hosted gateway route returns bounded body-free MetadataFacets while |
| 5 | * preserving auth, active vault, effective canister user, path safety, |
| 6 | * sanitized errors, and the no-body/no-full-frontmatter output boundary. |
| 7 | */ |
| 8 | import { test } from 'node:test'; |
| 9 | import assert from 'node:assert/strict'; |
| 10 | import http from 'http'; |
| 11 | import express from 'express'; |
| 12 | import crypto from 'crypto'; |
| 13 | import path from 'path'; |
| 14 | import { fileURLToPath, pathToFileURL } from 'url'; |
| 15 | |
| 16 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 17 | const projectRoot = path.resolve(__dirname, '..'); |
| 18 | const SECRET = 'gateway-metadata-facets-rest-test-secret-32'; |
| 19 | const GATEWAY_AUTH_SECRET = 'gateway-metadata-facets-gw-secret'; |
| 20 | |
| 21 | function signTestJwt(payload) { |
| 22 | const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); |
| 23 | const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); |
| 24 | const data = `${header}.${body}`; |
| 25 | const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url'); |
| 26 | return `${data}.${sig}`; |
| 27 | } |
| 28 | |
| 29 | function startServer(app) { |
| 30 | const srv = http.createServer(app); |
| 31 | return new Promise((resolve, reject) => { |
| 32 | srv.listen(0, '127.0.0.1', (err) => { |
| 33 | if (err) return reject(err); |
| 34 | resolve({ |
| 35 | url: `http://127.0.0.1:${srv.address().port}`, |
| 36 | close: () => new Promise((r) => srv.close(() => r())), |
| 37 | }); |
| 38 | }); |
| 39 | }); |
| 40 | } |
| 41 | |
| 42 | async function startGateway(canisterUrl, bridgeUrl) { |
| 43 | process.env.NETLIFY = '1'; |
| 44 | process.env.CANISTER_URL = canisterUrl; |
| 45 | process.env.BRIDGE_URL = bridgeUrl || ''; |
| 46 | process.env.SESSION_SECRET = SECRET; |
| 47 | process.env.CANISTER_AUTH_SECRET = GATEWAY_AUTH_SECRET; |
| 48 | process.env.BILLING_ENFORCE = 'false'; |
| 49 | |
| 50 | const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href; |
| 51 | const { app: gwApp } = await import(`${gwEntry}?gwmetadatafacets=${Date.now()}-${Math.random()}`); |
| 52 | return startServer(gwApp); |
| 53 | } |
| 54 | |
| 55 | test('unit: GET /api/v1/metadata-facets is auth-gated before upstream fetch', async (t) => { |
| 56 | const canisterCalls = []; |
| 57 | const canister = express(); |
| 58 | canister.get(/.*/, (req, res) => { |
| 59 | canisterCalls.push(req.originalUrl); |
| 60 | res.status(500).json({ error: 'must not be called' }); |
| 61 | }); |
| 62 | const canisterSrv = await startServer(canister); |
| 63 | t.after(canisterSrv.close); |
| 64 | const gateway = await startGateway(canisterSrv.url, ''); |
| 65 | t.after(gateway.close); |
| 66 | |
| 67 | const res = await fetch(`${gateway.url}/api/v1/metadata-facets?path=inbox/a.md`); |
| 68 | const body = await res.json(); |
| 69 | |
| 70 | assert.equal(res.status, 401); |
| 71 | assert.deepEqual(body, { error: 'Unauthorized', code: 'UNAUTHORIZED' }); |
| 72 | assert.equal(canisterCalls.length, 0); |
| 73 | }); |
| 74 | |
| 75 | test('integration: MetadataFacets REST uses active vault and effective canister user headers', async (t) => { |
| 76 | const bridgeCalls = []; |
| 77 | const bridge = express(); |
| 78 | bridge.get('/api/v1/hosted-context', (req, res) => { |
| 79 | bridgeCalls.push({ vault: req.headers['x-vault-id'], auth: req.headers.authorization }); |
| 80 | res.json({ |
| 81 | effective_canister_user_id: 'google:owner', |
| 82 | allowed_vault_ids: ['vault-facets'], |
| 83 | role: 'viewer', |
| 84 | }); |
| 85 | }); |
| 86 | const bridgeSrv = await startServer(bridge); |
| 87 | t.after(bridgeSrv.close); |
| 88 | |
| 89 | const canisterCalls = []; |
| 90 | const canister = express(); |
| 91 | canister.get('/api/v1/notes/:path', (req, res) => { |
| 92 | canisterCalls.push({ |
| 93 | url: req.originalUrl, |
| 94 | user: req.headers['x-user-id'], |
| 95 | actor: req.headers['x-actor-id'], |
| 96 | vault: req.headers['x-vault-id'], |
| 97 | gatewayAuth: req.headers['x-gateway-auth'], |
| 98 | }); |
| 99 | res.json({ |
| 100 | path: '/Users/private/upstream.md', |
| 101 | frontmatter: '{"project":"REST Facets","tags":["Alpha"],"api_key":"must-not-leak"}', |
| 102 | body: 'Body must not leak.', |
| 103 | }); |
| 104 | }); |
| 105 | const canisterSrv = await startServer(canister); |
| 106 | t.after(canisterSrv.close); |
| 107 | const gateway = await startGateway(canisterSrv.url, bridgeSrv.url); |
| 108 | t.after(gateway.close); |
| 109 | |
| 110 | const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); |
| 111 | const res = await fetch(`${gateway.url}/api/v1/metadata-facets?path=${encodeURIComponent('inbox/hello world.md')}`, { |
| 112 | headers: { |
| 113 | Authorization: `Bearer ${token}`, |
| 114 | 'X-Vault-Id': 'vault-facets', |
| 115 | }, |
| 116 | }); |
| 117 | const body = await res.json(); |
| 118 | |
| 119 | assert.equal(res.status, 200); |
| 120 | assert.equal(bridgeCalls.length, 1); |
| 121 | assert.equal(bridgeCalls[0].vault, 'vault-facets'); |
| 122 | assert.equal(bridgeCalls[0].auth, `Bearer ${token}`); |
| 123 | assert.equal(canisterCalls.length, 1); |
| 124 | assert.equal(canisterCalls[0].url, `/api/v1/notes/${encodeURIComponent('inbox/hello world.md')}`); |
| 125 | assert.equal(canisterCalls[0].user, 'google:owner'); |
| 126 | assert.equal(canisterCalls[0].actor, 'google:actor'); |
| 127 | assert.equal(canisterCalls[0].vault, 'vault-facets'); |
| 128 | assert.equal(canisterCalls[0].gatewayAuth, GATEWAY_AUTH_SECRET); |
| 129 | assert.equal(body.schema, 'knowtation.metadata_facets/v0'); |
| 130 | assert.equal(body.path, 'inbox/hello world.md'); |
| 131 | }); |
| 132 | |
| 133 | test('end-to-end: MetadataFacets REST returns the body-free allowlist only', async (t) => { |
| 134 | const canister = express(); |
| 135 | canister.get('/api/v1/notes/:path', (_req, res) => { |
| 136 | res.json({ |
| 137 | path: '/Users/private/upstream.md', |
| 138 | frontmatter: |
| 139 | '{"project":"REST Facets","tags":["Alpha","Beta"],"date":"2026-05-24","updated":"2026-05-25","causal_chain_id":"Launch Rollout","entity":["Alice B"],"episode_id":"Episode 1","api_key":"must-not-leak","label":"do not include"}', |
| 140 | body: 'Body must not leak.', |
| 141 | }); |
| 142 | }); |
| 143 | const canisterSrv = await startServer(canister); |
| 144 | t.after(canisterSrv.close); |
| 145 | const gateway = await startGateway(canisterSrv.url, ''); |
| 146 | t.after(gateway.close); |
| 147 | |
| 148 | const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); |
| 149 | const res = await fetch(`${gateway.url}/api/v1/metadata-facets?path=safe.md`, { |
| 150 | headers: { Authorization: `Bearer ${token}` }, |
| 151 | }); |
| 152 | const body = await res.json(); |
| 153 | const serialized = JSON.stringify(body); |
| 154 | |
| 155 | assert.equal(res.status, 200); |
| 156 | assert.deepEqual(body, { |
| 157 | schema: 'knowtation.metadata_facets/v0', |
| 158 | path: 'safe.md', |
| 159 | facets: { |
| 160 | project: 'rest-facets', |
| 161 | tags: ['alpha', 'beta'], |
| 162 | date: '2026-05-24', |
| 163 | updated: '2026-05-25', |
| 164 | causal_chain_id: 'launch-rollout', |
| 165 | entity: ['alice-b'], |
| 166 | episode_id: 'episode-1', |
| 167 | }, |
| 168 | inferred: { |
| 169 | folder: null, |
| 170 | source_type: null, |
| 171 | }, |
| 172 | truncated: false, |
| 173 | }); |
| 174 | assert.equal(Object.hasOwn(body, 'body'), false); |
| 175 | assert.equal(Object.hasOwn(body, 'frontmatter'), false); |
| 176 | assert.equal(Object.hasOwn(body, 'snippet'), false); |
| 177 | assert.equal(Object.hasOwn(body, 'summary'), false); |
| 178 | assert.equal(Object.hasOwn(body, 'labels'), false); |
| 179 | assert.equal(Object.hasOwn(body, 'resource_uri'), false); |
| 180 | assert.equal(serialized.includes('Body must not leak'), false); |
| 181 | assert.equal(serialized.includes('must-not-leak'), false); |
| 182 | assert.equal(serialized.includes('do not include'), false); |
| 183 | assert.equal(serialized.includes('/Users/private'), false); |
| 184 | assert.equal(serialized.includes('knowtation://'), false); |
| 185 | }); |
| 186 | |
| 187 | test('stress: MetadataFacets REST repeated calls are deterministic and one-note bounded', async (t) => { |
| 188 | const canisterCalls = []; |
| 189 | const canister = express(); |
| 190 | canister.get('/api/v1/notes/:path', (req, res) => { |
| 191 | canisterCalls.push(req.originalUrl); |
| 192 | res.json({ |
| 193 | path: 'ignored.md', |
| 194 | frontmatter: '{"tags":["Repeatable"]}', |
| 195 | body: 'private body', |
| 196 | }); |
| 197 | }); |
| 198 | canister.all(/.*/, (req, res) => { |
| 199 | canisterCalls.push(req.originalUrl); |
| 200 | res.status(500).json({ error: 'unexpected route' }); |
| 201 | }); |
| 202 | const canisterSrv = await startServer(canister); |
| 203 | t.after(canisterSrv.close); |
| 204 | const gateway = await startGateway(canisterSrv.url, ''); |
| 205 | t.after(gateway.close); |
| 206 | const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); |
| 207 | |
| 208 | const outputs = []; |
| 209 | for (let index = 0; index < 5; index += 1) { |
| 210 | const res = await fetch(`${gateway.url}/api/v1/metadata-facets?path=repeat.md`, { |
| 211 | headers: { Authorization: `Bearer ${token}` }, |
| 212 | }); |
| 213 | outputs.push(await res.text()); |
| 214 | } |
| 215 | |
| 216 | assert.equal(new Set(outputs).size, 1); |
| 217 | assert.equal(canisterCalls.length, 5); |
| 218 | assert.equal(canisterCalls.every((url) => url === '/api/v1/notes/repeat.md'), true); |
| 219 | assert.equal(outputs[0].includes('private body'), false); |
| 220 | }); |
| 221 | |
| 222 | test('data-integrity: MetadataFacets REST rejects unsafe paths before upstream fetch', async (t) => { |
| 223 | const canisterCalls = []; |
| 224 | const canister = express(); |
| 225 | canister.get(/.*/, (req, res) => { |
| 226 | canisterCalls.push(req.originalUrl); |
| 227 | res.status(500).json({ error: 'must not be called' }); |
| 228 | }); |
| 229 | const canisterSrv = await startServer(canister); |
| 230 | t.after(canisterSrv.close); |
| 231 | const gateway = await startGateway(canisterSrv.url, ''); |
| 232 | t.after(gateway.close); |
| 233 | const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); |
| 234 | |
| 235 | for (const unsafePath of ['../secret.md', '/Users/owner/secret.md', 'C:\\Users\\owner\\secret.md']) { |
| 236 | const res = await fetch(`${gateway.url}/api/v1/metadata-facets?path=${encodeURIComponent(unsafePath)}`, { |
| 237 | headers: { Authorization: `Bearer ${token}` }, |
| 238 | }); |
| 239 | const body = await res.json(); |
| 240 | const serialized = JSON.stringify(body); |
| 241 | |
| 242 | assert.equal(res.status, 400); |
| 243 | assert.deepEqual(body, { error: 'Invalid path', code: 'INVALID_PATH' }); |
| 244 | assert.equal(serialized.includes('secret.md'), false); |
| 245 | assert.equal(serialized.includes('/Users'), false); |
| 246 | assert.equal(serialized.includes('C:'), false); |
| 247 | } |
| 248 | assert.equal(canisterCalls.length, 0); |
| 249 | }); |
| 250 | |
| 251 | test('performance: MetadataFacets REST does not call bridge search, index, memory, or providers', async (t) => { |
| 252 | const bridgeCalls = []; |
| 253 | const bridge = express(); |
| 254 | bridge.get('/api/v1/hosted-context', (_req, res) => { |
| 255 | bridgeCalls.push('/api/v1/hosted-context'); |
| 256 | res.json({ effective_canister_user_id: 'google:owner', allowed_vault_ids: ['default'], role: 'viewer' }); |
| 257 | }); |
| 258 | bridge.all(/.*/, (req, res) => { |
| 259 | bridgeCalls.push(req.originalUrl); |
| 260 | res.status(500).json({ error: 'unexpected bridge route' }); |
| 261 | }); |
| 262 | const bridgeSrv = await startServer(bridge); |
| 263 | t.after(bridgeSrv.close); |
| 264 | const canister = express(); |
| 265 | canister.get('/api/v1/notes/:path', (_req, res) => { |
| 266 | res.json({ frontmatter: '{"tags":["A"]}', body: 'Private body.' }); |
| 267 | }); |
| 268 | const canisterSrv = await startServer(canister); |
| 269 | t.after(canisterSrv.close); |
| 270 | const gateway = await startGateway(canisterSrv.url, bridgeSrv.url); |
| 271 | t.after(gateway.close); |
| 272 | const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); |
| 273 | |
| 274 | const res = await fetch(`${gateway.url}/api/v1/metadata-facets?path=a.md`, { |
| 275 | headers: { Authorization: `Bearer ${token}` }, |
| 276 | }); |
| 277 | |
| 278 | assert.equal(res.status, 200); |
| 279 | assert.deepEqual(bridgeCalls, ['/api/v1/hosted-context']); |
| 280 | }); |
| 281 | |
| 282 | test('security: MetadataFacets REST sanitizes missing, unauthorized, and upstream failures', async (t) => { |
| 283 | const canister = express(); |
| 284 | canister.get('/api/v1/notes/missing.md', (_req, res) => { |
| 285 | res.status(404).json({ error: 'not found', body: 'private missing body' }); |
| 286 | }); |
| 287 | canister.get('/api/v1/notes/private.md', (_req, res) => { |
| 288 | res.status(403).json({ error: 'forbidden', frontmatter: 'api_key: must-not-leak' }); |
| 289 | }); |
| 290 | canister.get('/api/v1/notes/broken.md', (_req, res) => { |
| 291 | res.status(500).json({ error: 'stack trace', body: 'private upstream body' }); |
| 292 | }); |
| 293 | const canisterSrv = await startServer(canister); |
| 294 | t.after(canisterSrv.close); |
| 295 | const gateway = await startGateway(canisterSrv.url, ''); |
| 296 | t.after(gateway.close); |
| 297 | const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); |
| 298 | |
| 299 | const missing = await fetch(`${gateway.url}/api/v1/metadata-facets?path=missing.md`, { |
| 300 | headers: { Authorization: `Bearer ${token}` }, |
| 301 | }); |
| 302 | const forbidden = await fetch(`${gateway.url}/api/v1/metadata-facets?path=private.md`, { |
| 303 | headers: { Authorization: `Bearer ${token}` }, |
| 304 | }); |
| 305 | const broken = await fetch(`${gateway.url}/api/v1/metadata-facets?path=broken.md`, { |
| 306 | headers: { Authorization: `Bearer ${token}` }, |
| 307 | }); |
| 308 | const payloads = [await missing.json(), await forbidden.json(), await broken.json()]; |
| 309 | const serialized = JSON.stringify(payloads); |
| 310 | |
| 311 | assert.equal(missing.status, 404); |
| 312 | assert.equal(forbidden.status, 403); |
| 313 | assert.equal(broken.status, 502); |
| 314 | assert.deepEqual(payloads, [ |
| 315 | { error: 'Not found', code: 'NOT_FOUND' }, |
| 316 | { error: 'Forbidden', code: 'FORBIDDEN' }, |
| 317 | { error: 'Upstream 500', code: 'BAD_GATEWAY' }, |
| 318 | ]); |
| 319 | assert.equal(serialized.includes('private missing body'), false); |
| 320 | assert.equal(serialized.includes('must-not-leak'), false); |
| 321 | assert.equal(serialized.includes('private upstream body'), false); |
| 322 | }); |