/** * Integration tests — proposal approve RBAC fix. * * Tests the end-to-end gateway logic for resolveHostedActorRole using a mocked * bridge HTTP server and mocked JWT verification, covering the four critical paths: * 1. Bridge OK → admin: approval allowed. * 2. Bridge OK → member: approval blocked. * 3. Bridge 401 (SESSION_SECRET mismatch): JWT fallback applied. * 4. Bridge network error: JWT fallback applied. * 5. Bridge returns member, HUB_ADMIN_USER_IDS includes sub: gateway override promotes to admin. */ import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; import jwt from 'jsonwebtoken'; const SECRET = 'test-secret-unit-suite'; const ADMIN_SUB = 'google:admin-user-id'; const MEMBER_SUB = 'google:member-user-id'; const GATEWAY_ADMIN_SUB = 'google:gateway-admin-only-id'; /** * Build a fake bridge server that returns a fixed role response. * @param {object} opts * @param {number} opts.statusCode - HTTP status code to return * @param {{ role: string, may_approve_proposals: boolean }|null} opts.body - response body * @param {boolean} opts.networkError - if true, destroy the socket immediately (simulate timeout) */ function makeBridgeServer(opts = {}) { const { statusCode = 200, body = null, networkError = false } = opts; const server = http.createServer((req, res) => { if (networkError) { req.socket.destroy(); return; } res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.end(body ? JSON.stringify(body) : ''); }); return server; } /** * Minimal mock of resolveHostedActorRole logic extracted for unit testing. * Mirrors the exact structure in hub/gateway/server.mjs. * * @param {object} opts * @param {string|null} opts.bridgeUrl - BRIDGE_URL equivalent * @param {string} opts.authHeader - Authorization header value * @param {string} opts.jwtSecret - SESSION_SECRET * @param {Set} opts.adminSet - adminUserIdsSet * @param {object|null} opts.hctx - hosted context from getHostedAccessContext * @param {string|null} opts.bridgeRole - role the bridge returns (null = bridge non-OK) * @param {number} opts.bridgeStatus - HTTP status from bridge (only used in pure mock) * @param {boolean} opts.envFallback - HUB_EVALUATOR_MAY_APPROVE * @param {string} opts.sub - actor's sub (getUserId result) */ async function resolveHostedActorRoleMock(opts) { const { bridgeUrl, authHeader, jwtSecret, adminSet, hctx, bridgeRole, bridgeStatus = 200, envFallback = false, sub, } = opts; function roleForSub(s) { return s && adminSet.has(s) ? 'admin' : 'member'; } let role = 'member'; let mayApproveProposals = false; if (hctx && typeof hctx.role === 'string') { role = hctx.role; if (typeof hctx.may_approve_proposals === 'boolean') { mayApproveProposals = hctx.may_approve_proposals; } else if (role === 'evaluator') { mayApproveProposals = envFallback; } } else if (bridgeUrl && authHeader) { let bridgeResolved = false; // Simulate bridge response if (bridgeStatus === 200 && bridgeRole !== null) { if (bridgeRole) { role = bridgeRole; bridgeResolved = true; } mayApproveProposals = role === 'admin'; } else if (bridgeStatus === 200 && bridgeRole === null) { // empty/malformed response } // !bridgeResolved fallback — mirrors gateway code exactly if (!bridgeResolved) { try { const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; if (token && jwtSecret) { const payload = jwt.verify(token, jwtSecret); role = payload.role || roleForSub(payload.sub); mayApproveProposals = role === 'admin' || (role === 'evaluator' && envFallback); } } catch (_) {} } } else { try { const token = authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; if (token && jwtSecret) { const payload = jwt.verify(token, jwtSecret); role = payload.role || roleForSub(payload.sub); mayApproveProposals = role === 'admin' || (role === 'evaluator' && envFallback); } } catch (_) {} } // Gateway admin override if (sub && role !== 'admin' && roleForSub(sub) === 'admin') { role = 'admin'; mayApproveProposals = true; } return { role, mayApproveProposals }; } function makeAdminToken(sub) { return jwt.sign({ sub, role: 'admin' }, SECRET, { expiresIn: '1h' }); } function makeMemberToken(sub) { return jwt.sign({ sub, role: 'member' }, SECRET, { expiresIn: '1h' }); } describe('resolveHostedActorRole — integration (mock bridge)', () => { const adminSet = new Set([ADMIN_SUB, GATEWAY_ADMIN_SUB]); test('bridge returns admin → role is admin, canApprove', async () => { const token = makeAdminToken(ADMIN_SUB); const result = await resolveHostedActorRoleMock({ bridgeUrl: 'http://localhost:9999', authHeader: `Bearer ${token}`, jwtSecret: SECRET, adminSet, hctx: null, bridgeRole: 'admin', bridgeStatus: 200, sub: ADMIN_SUB, }); assert.equal(result.role, 'admin'); assert.equal(result.mayApproveProposals, true); }); test('bridge returns member → role is member, cannot approve (no gateway override)', async () => { const token = makeMemberToken(MEMBER_SUB); const result = await resolveHostedActorRoleMock({ bridgeUrl: 'http://localhost:9999', authHeader: `Bearer ${token}`, jwtSecret: SECRET, adminSet, hctx: null, bridgeRole: 'member', bridgeStatus: 200, sub: MEMBER_SUB, }); assert.equal(result.role, 'member'); assert.equal(result.mayApproveProposals, false); }); test('bridge 401 (SESSION_SECRET mismatch) → JWT fallback → admin JWT results in admin role', async () => { const token = makeAdminToken(ADMIN_SUB); const result = await resolveHostedActorRoleMock({ bridgeUrl: 'http://localhost:9999', authHeader: `Bearer ${token}`, jwtSecret: SECRET, adminSet, hctx: null, bridgeRole: null, // non-OK response bridgeStatus: 401, sub: ADMIN_SUB, }); // JWT has role: 'admin' so fallback returns admin assert.equal(result.role, 'admin'); assert.equal(result.mayApproveProposals, true); }); test('bridge 401 → JWT fallback → member JWT results in member role (no spoofing)', async () => { const token = makeMemberToken(MEMBER_SUB); const result = await resolveHostedActorRoleMock({ bridgeUrl: 'http://localhost:9999', authHeader: `Bearer ${token}`, jwtSecret: SECRET, adminSet, hctx: null, bridgeRole: null, bridgeStatus: 401, sub: MEMBER_SUB, }); assert.equal(result.role, 'member'); assert.equal(result.mayApproveProposals, false); }); test('bridge returns member, sub in gateway HUB_ADMIN_USER_IDS → gateway override promotes to admin', async () => { // GATEWAY_ADMIN_SUB is in adminSet but their JWT might say 'member' const token = makeMemberToken(GATEWAY_ADMIN_SUB); const result = await resolveHostedActorRoleMock({ bridgeUrl: 'http://localhost:9999', authHeader: `Bearer ${token}`, jwtSecret: SECRET, adminSet, // GATEWAY_ADMIN_SUB is in adminSet hctx: null, bridgeRole: 'member', bridgeStatus: 200, sub: GATEWAY_ADMIN_SUB, }); assert.equal(result.role, 'admin', 'gateway override promotes gateway admin sub'); assert.equal(result.mayApproveProposals, true); }); test('hctx present with admin role → admin (highest priority, no fallback needed)', async () => { const token = makeMemberToken(MEMBER_SUB); const result = await resolveHostedActorRoleMock({ bridgeUrl: 'http://localhost:9999', authHeader: `Bearer ${token}`, jwtSecret: SECRET, adminSet, hctx: { role: 'admin', may_approve_proposals: true }, bridgeRole: 'member', sub: MEMBER_SUB, }); assert.equal(result.role, 'admin'); assert.equal(result.mayApproveProposals, true); }); test('no BRIDGE_URL → falls through to JWT path (no bridge call)', async () => { const token = makeAdminToken(ADMIN_SUB); const result = await resolveHostedActorRoleMock({ bridgeUrl: null, authHeader: `Bearer ${token}`, jwtSecret: SECRET, adminSet, hctx: null, bridgeRole: null, sub: ADMIN_SUB, }); assert.equal(result.role, 'admin', 'JWT payload role used when no bridge URL'); assert.equal(result.mayApproveProposals, true); }); test('expired JWT with no bridge → role stays member (fail-closed)', async () => { const token = jwt.sign({ sub: MEMBER_SUB, role: 'admin' }, SECRET, { expiresIn: '-1s' }); const result = await resolveHostedActorRoleMock({ bridgeUrl: null, authHeader: `Bearer ${token}`, jwtSecret: SECRET, adminSet: new Set(), // empty admin set hctx: null, bridgeRole: null, sub: MEMBER_SUB, }); // Expired token → jwt.verify throws → role stays 'member' assert.equal(result.role, 'member', 'expired JWT without bridge → fail-closed member'); }); });