gateway-session-introspection.test.mjs
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