gateway-metadata-facets-rest.test.mjs file-level

at sha256:f · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 fix(security): pin patched transitive deps to clear Dependabot moderate… · aaronrene · Jun 11, 2026
1 /**
2 * Gateway REST MetadataFacets tests.
3 *
4 * The hosted gateway route returns bounded body-free MetadataFacets while
5 * preserving auth, active vault, effective canister user, path safety,
6 * sanitized errors, and the no-body/no-full-frontmatter 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-metadata-facets-rest-test-secret-32';
19 const GATEWAY_AUTH_SECRET = 'gateway-metadata-facets-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}?gwmetadatafacets=${Date.now()}-${Math.random()}`);
52 return startServer(gwApp);
53 }
54
55 test('unit: GET /api/v1/metadata-facets 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/metadata-facets?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: MetadataFacets 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-facets'],
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: '{"project":"REST Facets","tags":["Alpha"],"api_key":"must-not-leak"}',
102 body: 'Body must not leak.',
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/metadata-facets?path=${encodeURIComponent('inbox/hello world.md')}`, {
112 headers: {
113 Authorization: `Bearer ${token}`,
114 'X-Vault-Id': 'vault-facets',
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-facets');
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-facets');
128 assert.equal(canisterCalls[0].gatewayAuth, GATEWAY_AUTH_SECRET);
129 assert.equal(body.schema, 'knowtation.metadata_facets/v0');
130 assert.equal(body.path, 'inbox/hello world.md');
131 });
132
133 test('end-to-end: MetadataFacets 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:
139 '{"project":"REST Facets","tags":["Alpha","Beta"],"date":"2026-05-24","updated":"2026-05-25","causal_chain_id":"Launch Rollout","entity":["Alice B"],"episode_id":"Episode 1","api_key":"must-not-leak","label":"do not include"}',
140 body: 'Body must not leak.',
141 });
142 });
143 const canisterSrv = await startServer(canister);
144 t.after(canisterSrv.close);
145 const gateway = await startGateway(canisterSrv.url, '');
146 t.after(gateway.close);
147
148 const token = signTestJwt({ sub: 'google:actor', role: 'viewer' });
149 const res = await fetch(`${gateway.url}/api/v1/metadata-facets?path=safe.md`, {
150 headers: { Authorization: `Bearer ${token}` },
151 });
152 const body = await res.json();
153 const serialized = JSON.stringify(body);
154
155 assert.equal(res.status, 200);
156 assert.deepEqual(body, {
157 schema: 'knowtation.metadata_facets/v0',
158 path: 'safe.md',
159 facets: {
160 project: 'rest-facets',
161 tags: ['alpha', 'beta'],
162 date: '2026-05-24',
163 updated: '2026-05-25',
164 causal_chain_id: 'launch-rollout',
165 entity: ['alice-b'],
166 episode_id: 'episode-1',
167 },
168 inferred: {
169 folder: null,
170 source_type: null,
171 },
172 truncated: false,
173 });
174 assert.equal(Object.hasOwn(body, 'body'), false);
175 assert.equal(Object.hasOwn(body, 'frontmatter'), false);
176 assert.equal(Object.hasOwn(body, 'snippet'), false);
177 assert.equal(Object.hasOwn(body, 'summary'), false);
178 assert.equal(Object.hasOwn(body, 'labels'), false);
179 assert.equal(Object.hasOwn(body, 'resource_uri'), false);
180 assert.equal(serialized.includes('Body must not leak'), false);
181 assert.equal(serialized.includes('must-not-leak'), false);
182 assert.equal(serialized.includes('do not include'), false);
183 assert.equal(serialized.includes('/Users/private'), false);
184 assert.equal(serialized.includes('knowtation://'), false);
185 });
186
187 test('stress: MetadataFacets REST repeated calls are deterministic and one-note bounded', async (t) => {
188 const canisterCalls = [];
189 const canister = express();
190 canister.get('/api/v1/notes/:path', (req, res) => {
191 canisterCalls.push(req.originalUrl);
192 res.json({
193 path: 'ignored.md',
194 frontmatter: '{"tags":["Repeatable"]}',
195 body: 'private body',
196 });
197 });
198 canister.all(/.*/, (req, res) => {
199 canisterCalls.push(req.originalUrl);
200 res.status(500).json({ error: 'unexpected route' });
201 });
202 const canisterSrv = await startServer(canister);
203 t.after(canisterSrv.close);
204 const gateway = await startGateway(canisterSrv.url, '');
205 t.after(gateway.close);
206 const token = signTestJwt({ sub: 'google:actor', role: 'viewer' });
207
208 const outputs = [];
209 for (let index = 0; index < 5; index += 1) {
210 const res = await fetch(`${gateway.url}/api/v1/metadata-facets?path=repeat.md`, {
211 headers: { Authorization: `Bearer ${token}` },
212 });
213 outputs.push(await res.text());
214 }
215
216 assert.equal(new Set(outputs).size, 1);
217 assert.equal(canisterCalls.length, 5);
218 assert.equal(canisterCalls.every((url) => url === '/api/v1/notes/repeat.md'), true);
219 assert.equal(outputs[0].includes('private body'), false);
220 });
221
222 test('data-integrity: MetadataFacets REST rejects unsafe paths before upstream fetch', async (t) => {
223 const canisterCalls = [];
224 const canister = express();
225 canister.get(/.*/, (req, res) => {
226 canisterCalls.push(req.originalUrl);
227 res.status(500).json({ error: 'must not be called' });
228 });
229 const canisterSrv = await startServer(canister);
230 t.after(canisterSrv.close);
231 const gateway = await startGateway(canisterSrv.url, '');
232 t.after(gateway.close);
233 const token = signTestJwt({ sub: 'google:actor', role: 'viewer' });
234
235 for (const unsafePath of ['../secret.md', '/Users/owner/secret.md', 'C:\\Users\\owner\\secret.md']) {
236 const res = await fetch(`${gateway.url}/api/v1/metadata-facets?path=${encodeURIComponent(unsafePath)}`, {
237 headers: { Authorization: `Bearer ${token}` },
238 });
239 const body = await res.json();
240 const serialized = JSON.stringify(body);
241
242 assert.equal(res.status, 400);
243 assert.deepEqual(body, { error: 'Invalid path', code: 'INVALID_PATH' });
244 assert.equal(serialized.includes('secret.md'), false);
245 assert.equal(serialized.includes('/Users'), false);
246 assert.equal(serialized.includes('C:'), false);
247 }
248 assert.equal(canisterCalls.length, 0);
249 });
250
251 test('performance: MetadataFacets REST does not call bridge search, index, memory, or providers', async (t) => {
252 const bridgeCalls = [];
253 const bridge = express();
254 bridge.get('/api/v1/hosted-context', (_req, res) => {
255 bridgeCalls.push('/api/v1/hosted-context');
256 res.json({ effective_canister_user_id: 'google:owner', allowed_vault_ids: ['default'], role: 'viewer' });
257 });
258 bridge.all(/.*/, (req, res) => {
259 bridgeCalls.push(req.originalUrl);
260 res.status(500).json({ error: 'unexpected bridge route' });
261 });
262 const bridgeSrv = await startServer(bridge);
263 t.after(bridgeSrv.close);
264 const canister = express();
265 canister.get('/api/v1/notes/:path', (_req, res) => {
266 res.json({ frontmatter: '{"tags":["A"]}', body: 'Private body.' });
267 });
268 const canisterSrv = await startServer(canister);
269 t.after(canisterSrv.close);
270 const gateway = await startGateway(canisterSrv.url, bridgeSrv.url);
271 t.after(gateway.close);
272 const token = signTestJwt({ sub: 'google:actor', role: 'viewer' });
273
274 const res = await fetch(`${gateway.url}/api/v1/metadata-facets?path=a.md`, {
275 headers: { Authorization: `Bearer ${token}` },
276 });
277
278 assert.equal(res.status, 200);
279 assert.deepEqual(bridgeCalls, ['/api/v1/hosted-context']);
280 });
281
282 test('security: MetadataFacets REST sanitizes missing, unauthorized, and upstream failures', async (t) => {
283 const canister = express();
284 canister.get('/api/v1/notes/missing.md', (_req, res) => {
285 res.status(404).json({ error: 'not found', body: 'private missing body' });
286 });
287 canister.get('/api/v1/notes/private.md', (_req, res) => {
288 res.status(403).json({ error: 'forbidden', frontmatter: 'api_key: must-not-leak' });
289 });
290 canister.get('/api/v1/notes/broken.md', (_req, res) => {
291 res.status(500).json({ error: 'stack trace', body: 'private upstream body' });
292 });
293 const canisterSrv = await startServer(canister);
294 t.after(canisterSrv.close);
295 const gateway = await startGateway(canisterSrv.url, '');
296 t.after(gateway.close);
297 const token = signTestJwt({ sub: 'google:actor', role: 'viewer' });
298
299 const missing = await fetch(`${gateway.url}/api/v1/metadata-facets?path=missing.md`, {
300 headers: { Authorization: `Bearer ${token}` },
301 });
302 const forbidden = await fetch(`${gateway.url}/api/v1/metadata-facets?path=private.md`, {
303 headers: { Authorization: `Bearer ${token}` },
304 });
305 const broken = await fetch(`${gateway.url}/api/v1/metadata-facets?path=broken.md`, {
306 headers: { Authorization: `Bearer ${token}` },
307 });
308 const payloads = [await missing.json(), await forbidden.json(), await broken.json()];
309 const serialized = JSON.stringify(payloads);
310
311 assert.equal(missing.status, 404);
312 assert.equal(forbidden.status, 403);
313 assert.equal(broken.status, 502);
314 assert.deepEqual(payloads, [
315 { error: 'Not found', code: 'NOT_FOUND' },
316 { error: 'Forbidden', code: 'FORBIDDEN' },
317 { error: 'Upstream 500', code: 'BAD_GATEWAY' },
318 ]);
319 assert.equal(serialized.includes('private missing body'), false);
320 assert.equal(serialized.includes('must-not-leak'), false);
321 assert.equal(serialized.includes('private upstream body'), false);
322 });