gateway-document-tree-rest.test.mjs file-level

at sha256:3 · View file ↗ · Intel ↗

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