gateway-section-source-rest.test.mjs file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:6 feat(calendar): enforce agent context tiers in retrieval API (Phase 1E)… · aaronrene · Jun 18, 2026
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 });