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