gateway-session-introspection.test.mjs
443 lines 17.4 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 2 days ago
1 /**
2 * C7 Session Introspection — GET /api/v1/auth/session
3 *
4 * Tests all 7 tiers: unit, integration, e2e, stress, data-integrity, performance, security.
5 *
6 * Designed for Scooling (cross-origin, Bearer-only) and the Hub UI alike.
7 * The endpoint reads the verified JWT payload only — no extra DB call, no data elevation.
8 */
9
10 import { describe, it, before, after } from 'node:test';
11 import assert from 'node:assert/strict';
12 import http from 'node:http';
13 import crypto from 'node:crypto';
14 import fs from 'node:fs';
15 import path from 'node:path';
16 import { fileURLToPath, pathToFileURL } from 'node:url';
17
18 const __dirname = path.dirname(fileURLToPath(import.meta.url));
19 const ROOT = path.resolve(__dirname, '..');
20 const SECRET = 'c7-session-introspection-test-secret-32chars';
21 const SERVER_SRC = fs.readFileSync(
22 path.join(ROOT, 'hub', 'gateway', 'server.mjs'),
23 'utf8',
24 );
25
26 // ─── helpers ─────────────────────────────────────────────────────────────────
27
28 function makeJwt(payload, secret = SECRET) {
29 const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
30 const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
31 const sig = crypto
32 .createHmac('sha256', secret)
33 .update(`${header}.${body}`)
34 .digest('base64url');
35 return `${header}.${body}.${sig}`;
36 }
37
38 function validPayload(overrides = {}) {
39 const now = Math.floor(Date.now() / 1000);
40 return {
41 sub: 'google:123456789',
42 provider: 'google',
43 id: '123456789',
44 name: 'Test User',
45 role: 'member',
46 iat: now - 10,
47 exp: now + 900,
48 ...overrides,
49 };
50 }
51
52 function adminPayload(overrides = {}) {
53 return validPayload({
54 role: 'admin',
55 sub: 'github:admin1',
56 provider: 'github',
57 id: 'admin1',
58 ...overrides,
59 });
60 }
61
62 function startServer(app) {
63 const srv = http.createServer(app);
64 return new Promise((resolve, reject) => {
65 srv.listen(0, '127.0.0.1', (err) => {
66 if (err) return reject(err);
67 resolve({
68 url: `http://127.0.0.1:${srv.address().port}`,
69 close: () => new Promise((r) => srv.close(() => r())),
70 });
71 });
72 });
73 }
74
75 async function createGateway() {
76 process.env.NETLIFY = '1';
77 process.env.SESSION_SECRET = SECRET;
78 process.env.BILLING_ENFORCE = 'false';
79 process.env.CANISTER_URL = '';
80 process.env.BRIDGE_URL = '';
81 const entry = pathToFileURL(path.join(ROOT, 'hub', 'gateway', 'server.mjs')).href;
82 const { app } = await import(`${entry}?c7test=${Date.now()}-${Math.random()}`);
83 return startServer(app);
84 }
85
86 async function get(url, headers = {}) {
87 const res = await fetch(url, { headers });
88 const body = await res.json();
89 return { status: res.status, body, headers: res.headers };
90 }
91
92 // ─── 1. UNIT — structural wiring (no server needed) ──────────────────────────
93
94 describe('C7 unit: structural wiring in server.mjs', () => {
95 it('declares GET /api/v1/auth/session route', () => {
96 assert.ok(
97 SERVER_SRC.includes("'/api/v1/auth/session'"),
98 'route must be mounted',
99 );
100 });
101
102 it('declares decodeVerifiedToken helper that returns full payload (not just sub)', () => {
103 assert.ok(SERVER_SRC.includes('decodeVerifiedToken'), 'helper must exist');
104 assert.ok(
105 /function decodeVerifiedToken/.test(SERVER_SRC),
106 'must be a declared function',
107 );
108 // Must NOT just return sub — must return the full payload object
109 const fn = SERVER_SRC.slice(
110 SERVER_SRC.indexOf('function decodeVerifiedToken'),
111 SERVER_SRC.indexOf('function decodeVerifiedToken') + 200,
112 );
113 assert.ok(!fn.includes('.sub'), 'must return full payload, not just sub');
114 });
115
116 it('declares scopesForRole that includes vault scopes and admin', () => {
117 assert.ok(SERVER_SRC.includes('scopesForRole'), 'scopesForRole must exist');
118 assert.ok(SERVER_SRC.includes('vault:read'), 'must include vault:read scope');
119 assert.ok(SERVER_SRC.includes('vault:write'), 'must include vault:write scope');
120 assert.ok(SERVER_SRC.includes("'admin'"), 'must differentiate admin role');
121 });
122
123 it('mounts OPTIONS /api/v1/auth/session for CORS preflight', () => {
124 const idx = SERVER_SRC.indexOf("'/api/v1/auth/session'");
125 assert.ok(idx > 0, 'route must be present');
126 // Look for options() handler near the route declaration
127 const window = SERVER_SRC.slice(Math.max(0, idx - 350), idx + 50);
128 assert.ok(
129 window.includes('options') || window.includes('OPTIONS'),
130 'OPTIONS preflight must be handled',
131 );
132 });
133
134 it('response shape includes all required C7 contract fields', () => {
135 const idx = SERVER_SRC.indexOf("app.get('/api/v1/auth/session'");
136 assert.ok(idx > 0, 'route handler must exist');
137 const block = SERVER_SRC.slice(idx, idx + 700);
138 for (const field of ['sub', 'provider', 'id', 'name', 'role', 'iat', 'exp', 'scopes']) {
139 assert.ok(block.includes(field), `response must include field '${field}'`);
140 }
141 });
142
143 it('rejects missing or non-Bearer Authorization without reaching verifyToken', () => {
144 const idx = SERVER_SRC.indexOf("app.get('/api/v1/auth/session'");
145 const block = SERVER_SRC.slice(idx, idx + 400);
146 assert.ok(block.includes("startsWith('Bearer ')"), 'must check Bearer prefix');
147 assert.ok(block.includes('401'), 'must return 401 on missing auth');
148 });
149 });
150
151 // ─── HTTP tests — shared gateway server ──────────────────────────────────────
152
153 describe('C7 integration, e2e, stress, data-integrity, performance, security', () => {
154 let gw;
155
156 before(async () => {
157 gw = await createGateway();
158 });
159
160 after(async () => {
161 await gw.close();
162 });
163
164 // ─── 2. INTEGRATION ──────────────────────────────────────────────────────
165
166 it('integration: 401 with no Authorization header', async () => {
167 const { status, body } = await get(`${gw.url}/api/v1/auth/session`);
168 assert.equal(status, 401);
169 assert.equal(body.code, 'UNAUTHORIZED');
170 });
171
172 it('integration: 401 with non-Bearer scheme (Basic)', async () => {
173 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
174 Authorization: 'Basic abc123',
175 });
176 assert.equal(status, 401);
177 });
178
179 it('integration: 401 with empty Bearer value', async () => {
180 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
181 Authorization: 'Bearer ',
182 });
183 assert.equal(status, 401);
184 });
185
186 it('integration: 200 with valid member JWT — correct shape and scopes', async () => {
187 const token = makeJwt(validPayload());
188 const { status, body } = await get(`${gw.url}/api/v1/auth/session`, {
189 Authorization: `Bearer ${token}`,
190 });
191 assert.equal(status, 200);
192 assert.equal(body.sub, 'google:123456789');
193 assert.equal(body.provider, 'google');
194 assert.equal(body.id, '123456789');
195 assert.equal(body.name, 'Test User');
196 assert.equal(body.role, 'member');
197 assert.ok(Array.isArray(body.scopes), 'scopes must be an array');
198 assert.ok(body.scopes.includes('vault:read'), 'member must have vault:read');
199 assert.ok(body.scopes.includes('vault:write'), 'member must have vault:write');
200 assert.ok(!body.scopes.includes('admin'), 'member must not have admin scope');
201 assert.strictEqual(typeof body.iat, 'number', 'iat must be a number');
202 assert.strictEqual(typeof body.exp, 'number', 'exp must be a number');
203 });
204
205 it('integration: 200 with valid admin JWT — includes admin scope', async () => {
206 const token = makeJwt(adminPayload());
207 const { status, body } = await get(`${gw.url}/api/v1/auth/session`, {
208 Authorization: `Bearer ${token}`,
209 });
210 assert.equal(status, 200);
211 assert.equal(body.role, 'admin');
212 assert.ok(body.scopes.includes('admin'), 'admin must have admin scope');
213 assert.ok(body.scopes.includes('vault:read'), 'admin must retain vault:read');
214 });
215
216 it('integration: 200 for github provider — sub, provider, id correct', async () => {
217 const token = makeJwt(validPayload({ sub: 'github:9876', provider: 'github', id: '9876' }));
218 const { status, body } = await get(`${gw.url}/api/v1/auth/session`, {
219 Authorization: `Bearer ${token}`,
220 });
221 assert.equal(status, 200);
222 assert.equal(body.sub, 'github:9876');
223 assert.equal(body.provider, 'github');
224 assert.equal(body.id, '9876');
225 });
226
227 it('integration: response Content-Type is application/json', async () => {
228 const token = makeJwt(validPayload());
229 const res = await fetch(`${gw.url}/api/v1/auth/session`, {
230 headers: { Authorization: `Bearer ${token}` },
231 });
232 assert.ok(
233 res.headers.get('content-type')?.includes('application/json'),
234 'must return JSON content-type',
235 );
236 });
237
238 // ─── 3. END-TO-END ───────────────────────────────────────────────────────
239
240 it('e2e: refresh-path token (no display name) is accepted, safe defaults applied', async () => {
241 const now = Math.floor(Date.now() / 1000);
242 const token = makeJwt({
243 sub: 'google:refresh-user',
244 provider: 'google',
245 id: 'refresh-user',
246 name: '',
247 role: 'member',
248 iat: now - 5,
249 exp: now + 900,
250 });
251 const { status, body } = await get(`${gw.url}/api/v1/auth/session`, {
252 Authorization: `Bearer ${token}`,
253 });
254 assert.equal(status, 200);
255 assert.equal(body.sub, 'google:refresh-user');
256 assert.equal(body.name, '');
257 assert.ok(body.scopes.includes('vault:read'));
258 });
259
260 it('e2e: expired token is rejected with 401', async () => {
261 const now = Math.floor(Date.now() / 1000);
262 const token = makeJwt({ ...validPayload(), iat: now - 1000, exp: now - 1 });
263 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
264 Authorization: `Bearer ${token}`,
265 });
266 assert.equal(status, 401);
267 });
268
269 it('e2e: two concurrent calls with the same token return identical responses', async () => {
270 const token = makeJwt(validPayload());
271 const [a, b] = await Promise.all([
272 get(`${gw.url}/api/v1/auth/session`, { Authorization: `Bearer ${token}` }),
273 get(`${gw.url}/api/v1/auth/session`, { Authorization: `Bearer ${token}` }),
274 ]);
275 assert.equal(a.status, 200);
276 assert.equal(b.status, 200);
277 assert.deepEqual(a.body, b.body);
278 });
279
280 // ─── 4. STRESS ───────────────────────────────────────────────────────────
281
282 it('stress: 50 concurrent valid requests all succeed', async () => {
283 const token = makeJwt(validPayload());
284 const results = await Promise.all(
285 Array.from({ length: 50 }, () =>
286 get(`${gw.url}/api/v1/auth/session`, { Authorization: `Bearer ${token}` }),
287 ),
288 );
289 const failures = results.filter((r) => r.status !== 200);
290 assert.equal(failures.length, 0, `${failures.length}/50 requests failed`);
291 });
292
293 it('stress: 30 concurrent invalid requests all return 401', async () => {
294 const results = await Promise.all(
295 Array.from({ length: 30 }, () => get(`${gw.url}/api/v1/auth/session`)),
296 );
297 const nonAuth = results.filter((r) => r.status !== 401);
298 assert.equal(nonAuth.length, 0, 'all must be 401');
299 });
300
301 // ─── 5. DATA INTEGRITY ───────────────────────────────────────────────────
302
303 it('data-integrity: JWT secret is not present in the response', async () => {
304 const token = makeJwt(validPayload());
305 const { body } = await get(`${gw.url}/api/v1/auth/session`, {
306 Authorization: `Bearer ${token}`,
307 });
308 const bodyStr = JSON.stringify(body);
309 assert.ok(!bodyStr.includes(SECRET), 'response must not contain the signing secret');
310 });
311
312 it('data-integrity: extra JWT fields are not passed through to the response', async () => {
313 const token = makeJwt(validPayload({ extraField: 'MUST_NOT_LEAK' }));
314 const { body } = await get(`${gw.url}/api/v1/auth/session`, {
315 Authorization: `Bearer ${token}`,
316 });
317 assert.ok(!('extraField' in body), 'undocumented JWT fields must not appear in response');
318 });
319
320 it('data-integrity: scopes is always an array even when role is absent from token', async () => {
321 const now = Math.floor(Date.now() / 1000);
322 const token = makeJwt({
323 sub: 'google:norole',
324 provider: 'google',
325 id: 'norole',
326 name: '',
327 iat: now - 5,
328 exp: now + 900,
329 });
330 const { status, body } = await get(`${gw.url}/api/v1/auth/session`, {
331 Authorization: `Bearer ${token}`,
332 });
333 assert.equal(status, 200);
334 assert.ok(Array.isArray(body.scopes), 'scopes must always be an array');
335 assert.ok(body.scopes.length > 0, 'must default to at least one scope');
336 });
337
338 it('data-integrity: iat and exp are numbers (not strings)', async () => {
339 const token = makeJwt(validPayload());
340 const { body } = await get(`${gw.url}/api/v1/auth/session`, {
341 Authorization: `Bearer ${token}`,
342 });
343 assert.strictEqual(typeof body.iat, 'number');
344 assert.strictEqual(typeof body.exp, 'number');
345 });
346
347 it('data-integrity: sub is canonical provider:id for both google and github tokens', async () => {
348 for (const [provider, rawId] of [
349 ['google', '111'],
350 ['github', '222'],
351 ]) {
352 const token = makeJwt(validPayload({ sub: `${provider}:${rawId}`, provider, id: rawId }));
353 const { body } = await get(`${gw.url}/api/v1/auth/session`, {
354 Authorization: `Bearer ${token}`,
355 });
356 assert.equal(body.sub, `${provider}:${rawId}`);
357 assert.equal(body.provider, provider);
358 assert.equal(body.id, rawId);
359 }
360 });
361
362 // ─── 6. PERFORMANCE ──────────────────────────────────────────────────────
363
364 it('performance: p99 of 20 sequential calls is under 100ms', async () => {
365 const token = makeJwt(validPayload());
366 const times = [];
367 for (let i = 0; i < 20; i++) {
368 const t0 = performance.now();
369 await get(`${gw.url}/api/v1/auth/session`, { Authorization: `Bearer ${token}` });
370 times.push(performance.now() - t0);
371 }
372 times.sort((a, b) => a - b);
373 const p99 = times[Math.ceil(times.length * 0.99) - 1];
374 assert.ok(p99 < 100, `p99 ${p99.toFixed(1)}ms exceeds 100ms budget`);
375 });
376
377 // ─── 7. SECURITY ─────────────────────────────────────────────────────────
378
379 it('security: token signed with wrong secret is rejected', async () => {
380 const token = makeJwt(validPayload(), 'WRONG-SECRET-THAT-DOES-NOT-MATCH');
381 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
382 Authorization: `Bearer ${token}`,
383 });
384 assert.equal(status, 401);
385 });
386
387 it('security: tampered payload (one char flipped in body segment) is rejected', async () => {
388 const token = makeJwt(validPayload());
389 const parts = token.split('.');
390 const tampered = `${parts[0]}.${parts[1].slice(0, -1)}X.${parts[2]}`;
391 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
392 Authorization: `Bearer ${tampered}`,
393 });
394 assert.equal(status, 401);
395 });
396
397 it('security: alg:none token (algorithm confusion attack) is rejected', async () => {
398 const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
399 const body = Buffer.from(JSON.stringify(validPayload())).toString('base64url');
400 const noneToken = `${header}.${body}.`;
401 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
402 Authorization: `Bearer ${noneToken}`,
403 });
404 assert.equal(status, 401, 'alg:none tokens must be rejected');
405 });
406
407 it('security: empty sub in a valid-signature token is rejected', async () => {
408 const token = makeJwt({ ...validPayload(), sub: '' });
409 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
410 Authorization: `Bearer ${token}`,
411 });
412 assert.equal(status, 401, 'empty sub must be rejected — no anonymous identity');
413 });
414
415 it('security: token passed via query string (not header) is not accepted', async () => {
416 const token = makeJwt(validPayload());
417 const res = await fetch(`${gw.url}/api/v1/auth/session?token=${token}`);
418 assert.equal(res.status, 401, 'query-string token must not be accepted');
419 });
420
421 it('security: completely garbage token string returns 401', async () => {
422 const { status } = await get(`${gw.url}/api/v1/auth/session`, {
423 Authorization: 'Bearer this.is.not.a.jwt',
424 });
425 assert.equal(status, 401);
426 });
427
428 it('security: 401 response does not leak stack traces or internal paths', async () => {
429 const { body } = await get(`${gw.url}/api/v1/auth/session`, {
430 Authorization: 'Bearer garbage',
431 });
432 const s = JSON.stringify(body);
433 assert.ok(!s.includes('at '), 'must not leak stack trace');
434 assert.ok(!s.includes('node_modules'), 'must not leak internal paths');
435 });
436
437 it('security: 401 response does not include server version header', async () => {
438 const res = await fetch(`${gw.url}/api/v1/auth/session`, {
439 headers: { Authorization: 'Bearer garbage' },
440 });
441 assert.ok(!res.headers.get('x-powered-by'), 'must not expose x-powered-by');
442 });
443 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 2 days ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 3 days ago