/** * Gateway REST SectionSource tests. * * Phase 1N exposes body-free SectionSource v0 through Hub gateway REST only. * It must preserve auth, active vault, effective canister user, one-note reads, * path safety, sanitized errors, and the no-body/no-snippet output boundary. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import http from 'http'; import express from 'express'; import crypto from 'crypto'; import path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, '..'); const SECRET = 'gateway-section-source-rest-test-secret-32'; const GATEWAY_AUTH_SECRET = 'gateway-section-source-rest-gw-secret'; function signTestJwt(payload) { const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); const data = `${header}.${body}`; const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url'); return `${data}.${sig}`; } function startServer(app) { const srv = http.createServer(app); return new Promise((resolve, reject) => { srv.listen(0, '127.0.0.1', (err) => { if (err) return reject(err); resolve({ url: `http://127.0.0.1:${srv.address().port}`, close: () => new Promise((r) => srv.close(() => r())), }); }); }); } async function startGateway(canisterUrl, bridgeUrl) { process.env.NETLIFY = '1'; process.env.CANISTER_URL = canisterUrl; process.env.BRIDGE_URL = bridgeUrl || ''; process.env.SESSION_SECRET = SECRET; process.env.CANISTER_AUTH_SECRET = GATEWAY_AUTH_SECRET; process.env.BILLING_ENFORCE = 'false'; const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href; const { app: gwApp } = await import(`${gwEntry}?gwsection=${Date.now()}-${Math.random()}`); return startServer(gwApp); } test('unit: GET /api/v1/section-source is auth-gated before upstream fetch', async (t) => { const canisterCalls = []; const canister = express(); canister.get(/.*/, (req, res) => { canisterCalls.push(req.originalUrl); res.status(500).json({ error: 'must not be called' }); }); const canisterSrv = await startServer(canister); t.after(canisterSrv.close); const gateway = await startGateway(canisterSrv.url, ''); t.after(gateway.close); const res = await fetch(`${gateway.url}/api/v1/section-source?path=inbox/a.md`); const body = await res.json(); assert.equal(res.status, 401); assert.deepEqual(body, { error: 'Unauthorized', code: 'UNAUTHORIZED' }); assert.equal(canisterCalls.length, 0); }); test('integration: SectionSource REST uses active vault and effective canister user headers', async (t) => { const bridgeCalls = []; const bridge = express(); bridge.get('/api/v1/hosted-context', (req, res) => { bridgeCalls.push({ vault: req.headers['x-vault-id'], auth: req.headers.authorization }); res.json({ effective_canister_user_id: 'google:owner', allowed_vault_ids: ['vault-rest'], role: 'viewer', }); }); const bridgeSrv = await startServer(bridge); t.after(bridgeSrv.close); const canisterCalls = []; const canister = express(); canister.get('/api/v1/notes/:path', (req, res) => { canisterCalls.push({ url: req.originalUrl, user: req.headers['x-user-id'], actor: req.headers['x-actor-id'], vault: req.headers['x-vault-id'], gatewayAuth: req.headers['x-gateway-auth'], }); res.json({ path: '/Users/private/upstream.md', frontmatter: '{"title":"REST Section","api_key":"must-not-leak"}', body: '# Intro\n\nBody must not leak.\n\n## Next', }); }); const canisterSrv = await startServer(canister); t.after(canisterSrv.close); const gateway = await startGateway(canisterSrv.url, bridgeSrv.url); t.after(gateway.close); const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); const res = await fetch(`${gateway.url}/api/v1/section-source?path=${encodeURIComponent('inbox/hello world.md')}`, { headers: { Authorization: `Bearer ${token}`, 'X-Vault-Id': 'vault-rest', }, }); const body = await res.json(); assert.equal(res.status, 200); assert.equal(bridgeCalls.length, 1); assert.equal(bridgeCalls[0].vault, 'vault-rest'); assert.equal(bridgeCalls[0].auth, `Bearer ${token}`); assert.equal(canisterCalls.length, 1); assert.equal(canisterCalls[0].url, `/api/v1/notes/${encodeURIComponent('inbox/hello world.md')}`); assert.equal(canisterCalls[0].user, 'google:owner'); assert.equal(canisterCalls[0].actor, 'google:actor'); assert.equal(canisterCalls[0].vault, 'vault-rest'); assert.equal(canisterCalls[0].gatewayAuth, GATEWAY_AUTH_SECRET); assert.equal(body.schema, 'knowtation.section_source/v0'); assert.equal(body.path, 'inbox/hello world.md'); }); test('end-to-end: SectionSource REST returns the body-free allowlist only', async (t) => { const canister = express(); canister.get('/api/v1/notes/:path', (_req, res) => { res.json({ path: '/Users/private/upstream.md', frontmatter: '{"title":"REST Section","api_key":"must-not-leak"}', body: '# Intro\n\nBody must not leak.\n\n## Next\n\nMore private body.', }); }); const canisterSrv = await startServer(canister); t.after(canisterSrv.close); const gateway = await startGateway(canisterSrv.url, ''); t.after(gateway.close); const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); const res = await fetch(`${gateway.url}/api/v1/section-source?path=safe.md`, { headers: { Authorization: `Bearer ${token}` }, }); const body = await res.json(); const serialized = JSON.stringify(body); assert.equal(res.status, 200); assert.deepEqual(body, { schema: 'knowtation.section_source/v0', path: 'safe.md', title: 'REST Section', sections: [ { section_id: 'safe-md:h1-intro-0001', heading_id: 'h1-intro-0001', level: 1, heading_path: ['Intro'], heading_text: 'Intro', child_section_ids: ['safe-md:h2-next-0002'], body_available: true, body_returned: false, snippet_returned: false, }, { section_id: 'safe-md:h2-next-0002', heading_id: 'h2-next-0002', level: 2, heading_path: ['Intro', 'Next'], heading_text: 'Next', child_section_ids: [], body_available: true, body_returned: false, snippet_returned: false, }, ], truncated: false, }); assert.equal(Object.hasOwn(body, 'body'), false); assert.equal(Object.hasOwn(body, 'frontmatter'), false); assert.equal(Object.hasOwn(body, 'snippet'), false); assert.equal(Object.hasOwn(body, 'resource_uri'), false); assert.equal(serialized.includes('Body must not leak'), false); assert.equal(serialized.includes('More private body'), false); assert.equal(serialized.includes('must-not-leak'), false); assert.equal(serialized.includes('/Users/private'), false); assert.equal(serialized.includes('knowtation://'), false); }); test('stress: SectionSource REST repeated calls are deterministic and one-note bounded', async (t) => { const canisterCalls = []; const canister = express(); canister.get('/api/v1/notes/:path', (req, res) => { canisterCalls.push(req.originalUrl); res.json({ path: 'ignored.md', frontmatter: '{"title":"Repeatable"}', body: '# A\n\nAlpha private body.\n\n## B\n\nBeta private body.', }); }); canister.all(/.*/, (req, res) => { canisterCalls.push(req.originalUrl); res.status(500).json({ error: 'unexpected route' }); }); const canisterSrv = await startServer(canister); t.after(canisterSrv.close); const gateway = await startGateway(canisterSrv.url, ''); t.after(gateway.close); const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); const outputs = []; for (let index = 0; index < 5; index += 1) { const res = await fetch(`${gateway.url}/api/v1/section-source?path=repeat.md`, { headers: { Authorization: `Bearer ${token}` }, }); outputs.push(await res.text()); } assert.equal(new Set(outputs).size, 1); assert.equal(canisterCalls.length, 5); assert.equal(canisterCalls.every((url) => url === '/api/v1/notes/repeat.md'), true); assert.equal(outputs[0].includes('private body'), false); }); test('data-integrity: SectionSource REST rejects unsafe paths before upstream fetch', async (t) => { const canisterCalls = []; const canister = express(); canister.get(/.*/, (req, res) => { canisterCalls.push(req.originalUrl); res.status(500).json({ error: 'must not be called' }); }); const canisterSrv = await startServer(canister); t.after(canisterSrv.close); const gateway = await startGateway(canisterSrv.url, ''); t.after(gateway.close); const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); for (const unsafePath of ['../secret.md', '/Users/owner/secret.md', 'C:\\Users\\owner\\secret.md']) { const res = await fetch(`${gateway.url}/api/v1/section-source?path=${encodeURIComponent(unsafePath)}`, { headers: { Authorization: `Bearer ${token}` }, }); const body = await res.json(); const serialized = JSON.stringify(body); assert.equal(res.status, 400); assert.deepEqual(body, { error: 'Invalid path', code: 'INVALID_PATH' }); assert.equal(serialized.includes('secret.md'), false); assert.equal(serialized.includes('/Users'), false); assert.equal(serialized.includes('C:'), false); } assert.equal(canisterCalls.length, 0); }); test('performance: SectionSource REST does not call bridge search, index, memory, or providers', async (t) => { const bridgeCalls = []; const bridge = express(); bridge.get('/api/v1/hosted-context', (_req, res) => { bridgeCalls.push('/api/v1/hosted-context'); res.json({ effective_canister_user_id: 'google:owner', allowed_vault_ids: ['default'], role: 'viewer' }); }); bridge.all(/.*/, (req, res) => { bridgeCalls.push(req.originalUrl); res.status(500).json({ error: 'unexpected bridge route' }); }); const bridgeSrv = await startServer(bridge); t.after(bridgeSrv.close); const canister = express(); canister.get('/api/v1/notes/:path', (_req, res) => { res.json({ frontmatter: '{}', body: '# A\n\nPrivate body.' }); }); const canisterSrv = await startServer(canister); t.after(canisterSrv.close); const gateway = await startGateway(canisterSrv.url, bridgeSrv.url); t.after(gateway.close); const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); const res = await fetch(`${gateway.url}/api/v1/section-source?path=a.md`, { headers: { Authorization: `Bearer ${token}` }, }); assert.equal(res.status, 200); assert.deepEqual(bridgeCalls, ['/api/v1/hosted-context']); }); test('security: SectionSource REST sanitizes missing, unauthorized, and upstream failures', async (t) => { const canister = express(); canister.get('/api/v1/notes/missing.md', (_req, res) => { res.status(404).json({ error: 'not found', body: 'private missing body' }); }); canister.get('/api/v1/notes/private.md', (_req, res) => { res.status(403).json({ error: 'forbidden', frontmatter: 'api_key: must-not-leak' }); }); canister.get('/api/v1/notes/broken.md', (_req, res) => { res.status(500).json({ error: 'stack trace', body: 'private upstream body' }); }); const canisterSrv = await startServer(canister); t.after(canisterSrv.close); const gateway = await startGateway(canisterSrv.url, ''); t.after(gateway.close); const token = signTestJwt({ sub: 'google:actor', role: 'viewer' }); const missing = await fetch(`${gateway.url}/api/v1/section-source?path=missing.md`, { headers: { Authorization: `Bearer ${token}` }, }); const forbidden = await fetch(`${gateway.url}/api/v1/section-source?path=private.md`, { headers: { Authorization: `Bearer ${token}` }, }); const broken = await fetch(`${gateway.url}/api/v1/section-source?path=broken.md`, { headers: { Authorization: `Bearer ${token}` }, }); const payloads = [await missing.json(), await forbidden.json(), await broken.json()]; const serialized = JSON.stringify(payloads); assert.equal(missing.status, 404); assert.equal(forbidden.status, 403); assert.equal(broken.status, 502); assert.deepEqual(payloads, [ { error: 'Not found', code: 'NOT_FOUND' }, { error: 'Forbidden', code: 'FORBIDDEN' }, { error: 'Upstream 500', code: 'BAD_GATEWAY' }, ]); assert.equal(serialized.includes('private missing body'), false); assert.equal(serialized.includes('must-not-leak'), false); assert.equal(serialized.includes('private upstream body'), false); });