gateway-section-source-rest.test.mjs
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | /** |
| 2 | * Gateway REST SectionSource tests. |
| 3 | * |
| 4 | * Phase 1N exposes body-free SectionSource v0 through Hub gateway REST only. |
| 5 | * It must preserve auth, active vault, effective canister user, one-note reads, |
| 6 | * path safety, sanitized errors, and the no-body/no-snippet 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-section-source-rest-test-secret-32'; |
| 19 | const GATEWAY_AUTH_SECRET = 'gateway-section-source-rest-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}?gwsection=${Date.now()}-${Math.random()}`); |
| 52 | return startServer(gwApp); |
| 53 | } |
| 54 | |
| 55 | test('unit: GET /api/v1/section-source 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/section-source?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: SectionSource 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-rest'], |
| 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: '{"title":"REST Section","api_key":"must-not-leak"}', |
| 102 | body: '# Intro\n\nBody must not leak.\n\n## Next', |
| 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/section-source?path=${encodeURIComponent('inbox/hello world.md')}`, { |
| 112 | headers: { |
| 113 | Authorization: `Bearer ${token}`, |
| 114 | 'X-Vault-Id': 'vault-rest', |
| 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-rest'); |
| 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-rest'); |
| 128 | assert.equal(canisterCalls[0].gatewayAuth, GATEWAY_AUTH_SECRET); |
| 129 | assert.equal(body.schema, 'knowtation.section_source/v0'); |
| 130 | assert.equal(body.path, 'inbox/hello world.md'); |
| 131 | }); |
| 132 | |
| 133 | test('end-to-end: SectionSource 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: '{"title":"REST Section","api_key":"must-not-leak"}', |
| 139 | body: '# Intro\n\nBody must not leak.\n\n## Next\n\nMore private body.', |
| 140 | }); |
| 141 | }); |
| 142 | const canisterSrv = await startServer(canister); |
| 143 | t.after(canisterSrv.close); |
| 144 | const gateway = await startGateway(canisterSrv.url, ''); |
| 145 | t.after(gateway.close); |
| 146 | |
| 147 | const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); |
| 148 | const res = await fetch(`${gateway.url}/api/v1/section-source?path=safe.md`, { |
| 149 | headers: { Authorization: `Bearer ${token}` }, |
| 150 | }); |
| 151 | const body = await res.json(); |
| 152 | const serialized = JSON.stringify(body); |
| 153 | |
| 154 | assert.equal(res.status, 200); |
| 155 | assert.deepEqual(body, { |
| 156 | schema: 'knowtation.section_source/v0', |
| 157 | path: 'safe.md', |
| 158 | title: 'REST Section', |
| 159 | sections: [ |
| 160 | { |
| 161 | section_id: 'safe-md:h1-intro-0001', |
| 162 | heading_id: 'h1-intro-0001', |
| 163 | level: 1, |
| 164 | heading_path: ['Intro'], |
| 165 | heading_text: 'Intro', |
| 166 | child_section_ids: ['safe-md:h2-next-0002'], |
| 167 | body_available: true, |
| 168 | body_returned: false, |
| 169 | snippet_returned: false, |
| 170 | }, |
| 171 | { |
| 172 | section_id: 'safe-md:h2-next-0002', |
| 173 | heading_id: 'h2-next-0002', |
| 174 | level: 2, |
| 175 | heading_path: ['Intro', 'Next'], |
| 176 | heading_text: 'Next', |
| 177 | child_section_ids: [], |
| 178 | body_available: true, |
| 179 | body_returned: false, |
| 180 | snippet_returned: false, |
| 181 | }, |
| 182 | ], |
| 183 | truncated: false, |
| 184 | }); |
| 185 | assert.equal(Object.hasOwn(body, 'body'), false); |
| 186 | assert.equal(Object.hasOwn(body, 'frontmatter'), false); |
| 187 | assert.equal(Object.hasOwn(body, 'snippet'), false); |
| 188 | assert.equal(Object.hasOwn(body, 'resource_uri'), false); |
| 189 | assert.equal(serialized.includes('Body must not leak'), false); |
| 190 | assert.equal(serialized.includes('More private body'), false); |
| 191 | assert.equal(serialized.includes('must-not-leak'), false); |
| 192 | assert.equal(serialized.includes('/Users/private'), false); |
| 193 | assert.equal(serialized.includes('knowtation://'), false); |
| 194 | }); |
| 195 | |
| 196 | test('stress: SectionSource REST repeated calls are deterministic and one-note bounded', async (t) => { |
| 197 | const canisterCalls = []; |
| 198 | const canister = express(); |
| 199 | canister.get('/api/v1/notes/:path', (req, res) => { |
| 200 | canisterCalls.push(req.originalUrl); |
| 201 | res.json({ |
| 202 | path: 'ignored.md', |
| 203 | frontmatter: '{"title":"Repeatable"}', |
| 204 | body: '# A\n\nAlpha private body.\n\n## B\n\nBeta private body.', |
| 205 | }); |
| 206 | }); |
| 207 | canister.all(/.*/, (req, res) => { |
| 208 | canisterCalls.push(req.originalUrl); |
| 209 | res.status(500).json({ error: 'unexpected route' }); |
| 210 | }); |
| 211 | const canisterSrv = await startServer(canister); |
| 212 | t.after(canisterSrv.close); |
| 213 | const gateway = await startGateway(canisterSrv.url, ''); |
| 214 | t.after(gateway.close); |
| 215 | const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); |
| 216 | |
| 217 | const outputs = []; |
| 218 | for (let index = 0; index < 5; index += 1) { |
| 219 | const res = await fetch(`${gateway.url}/api/v1/section-source?path=repeat.md`, { |
| 220 | headers: { Authorization: `Bearer ${token}` }, |
| 221 | }); |
| 222 | outputs.push(await res.text()); |
| 223 | } |
| 224 | |
| 225 | assert.equal(new Set(outputs).size, 1); |
| 226 | assert.equal(canisterCalls.length, 5); |
| 227 | assert.equal(canisterCalls.every((url) => url === '/api/v1/notes/repeat.md'), true); |
| 228 | assert.equal(outputs[0].includes('private body'), false); |
| 229 | }); |
| 230 | |
| 231 | test('data-integrity: SectionSource REST rejects unsafe paths before upstream fetch', async (t) => { |
| 232 | const canisterCalls = []; |
| 233 | const canister = express(); |
| 234 | canister.get(/.*/, (req, res) => { |
| 235 | canisterCalls.push(req.originalUrl); |
| 236 | res.status(500).json({ error: 'must not be called' }); |
| 237 | }); |
| 238 | const canisterSrv = await startServer(canister); |
| 239 | t.after(canisterSrv.close); |
| 240 | const gateway = await startGateway(canisterSrv.url, ''); |
| 241 | t.after(gateway.close); |
| 242 | const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); |
| 243 | |
| 244 | for (const unsafePath of ['../secret.md', '/Users/owner/secret.md', 'C:\\Users\\owner\\secret.md']) { |
| 245 | const res = await fetch(`${gateway.url}/api/v1/section-source?path=${encodeURIComponent(unsafePath)}`, { |
| 246 | headers: { Authorization: `Bearer ${token}` }, |
| 247 | }); |
| 248 | const body = await res.json(); |
| 249 | const serialized = JSON.stringify(body); |
| 250 | |
| 251 | assert.equal(res.status, 400); |
| 252 | assert.deepEqual(body, { error: 'Invalid path', code: 'INVALID_PATH' }); |
| 253 | assert.equal(serialized.includes('secret.md'), false); |
| 254 | assert.equal(serialized.includes('/Users'), false); |
| 255 | assert.equal(serialized.includes('C:'), false); |
| 256 | } |
| 257 | assert.equal(canisterCalls.length, 0); |
| 258 | }); |
| 259 | |
| 260 | test('performance: SectionSource REST does not call bridge search, index, memory, or providers', async (t) => { |
| 261 | const bridgeCalls = []; |
| 262 | const bridge = express(); |
| 263 | bridge.get('/api/v1/hosted-context', (_req, res) => { |
| 264 | bridgeCalls.push('/api/v1/hosted-context'); |
| 265 | res.json({ effective_canister_user_id: 'google:owner', allowed_vault_ids: ['default'], role: 'viewer' }); |
| 266 | }); |
| 267 | bridge.all(/.*/, (req, res) => { |
| 268 | bridgeCalls.push(req.originalUrl); |
| 269 | res.status(500).json({ error: 'unexpected bridge route' }); |
| 270 | }); |
| 271 | const bridgeSrv = await startServer(bridge); |
| 272 | t.after(bridgeSrv.close); |
| 273 | const canister = express(); |
| 274 | canister.get('/api/v1/notes/:path', (_req, res) => { |
| 275 | res.json({ frontmatter: '{}', body: '# A\n\nPrivate body.' }); |
| 276 | }); |
| 277 | const canisterSrv = await startServer(canister); |
| 278 | t.after(canisterSrv.close); |
| 279 | const gateway = await startGateway(canisterSrv.url, bridgeSrv.url); |
| 280 | t.after(gateway.close); |
| 281 | const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); |
| 282 | |
| 283 | const res = await fetch(`${gateway.url}/api/v1/section-source?path=a.md`, { |
| 284 | headers: { Authorization: `Bearer ${token}` }, |
| 285 | }); |
| 286 | |
| 287 | assert.equal(res.status, 200); |
| 288 | assert.deepEqual(bridgeCalls, ['/api/v1/hosted-context']); |
| 289 | }); |
| 290 | |
| 291 | test('security: SectionSource REST sanitizes missing, unauthorized, and upstream failures', async (t) => { |
| 292 | const canister = express(); |
| 293 | canister.get('/api/v1/notes/missing.md', (_req, res) => { |
| 294 | res.status(404).json({ error: 'not found', body: 'private missing body' }); |
| 295 | }); |
| 296 | canister.get('/api/v1/notes/private.md', (_req, res) => { |
| 297 | res.status(403).json({ error: 'forbidden', frontmatter: 'api_key: must-not-leak' }); |
| 298 | }); |
| 299 | canister.get('/api/v1/notes/broken.md', (_req, res) => { |
| 300 | res.status(500).json({ error: 'stack trace', body: 'private upstream body' }); |
| 301 | }); |
| 302 | const canisterSrv = await startServer(canister); |
| 303 | t.after(canisterSrv.close); |
| 304 | const gateway = await startGateway(canisterSrv.url, ''); |
| 305 | t.after(gateway.close); |
| 306 | const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); |
| 307 | |
| 308 | const missing = await fetch(`${gateway.url}/api/v1/section-source?path=missing.md`, { |
| 309 | headers: { Authorization: `Bearer ${token}` }, |
| 310 | }); |
| 311 | const forbidden = await fetch(`${gateway.url}/api/v1/section-source?path=private.md`, { |
| 312 | headers: { Authorization: `Bearer ${token}` }, |
| 313 | }); |
| 314 | const broken = await fetch(`${gateway.url}/api/v1/section-source?path=broken.md`, { |
| 315 | headers: { Authorization: `Bearer ${token}` }, |
| 316 | }); |
| 317 | const payloads = [await missing.json(), await forbidden.json(), await broken.json()]; |
| 318 | const serialized = JSON.stringify(payloads); |
| 319 | |
| 320 | assert.equal(missing.status, 404); |
| 321 | assert.equal(forbidden.status, 403); |
| 322 | assert.equal(broken.status, 502); |
| 323 | assert.deepEqual(payloads, [ |
| 324 | { error: 'Not found', code: 'NOT_FOUND' }, |
| 325 | { error: 'Forbidden', code: 'FORBIDDEN' }, |
| 326 | { error: 'Upstream 500', code: 'BAD_GATEWAY' }, |
| 327 | ]); |
| 328 | assert.equal(serialized.includes('private missing body'), false); |
| 329 | assert.equal(serialized.includes('must-not-leak'), false); |
| 330 | assert.equal(serialized.includes('private upstream body'), false); |
| 331 | }); |