proposal-approve-rbac-fix-integration.test.mjs
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb
reconcile: import GitHub-direct RBAC/OAuth/companion and ho…
Human
minor
⚠ breaking
13 hours ago
| 1 | /** |
| 2 | * Integration tests — proposal approve RBAC fix. |
| 3 | * |
| 4 | * Tests the end-to-end gateway logic for resolveHostedActorRole using a mocked |
| 5 | * bridge HTTP server and mocked JWT verification, covering the four critical paths: |
| 6 | * 1. Bridge OK → admin: approval allowed. |
| 7 | * 2. Bridge OK → member: approval blocked. |
| 8 | * 3. Bridge 401 (SESSION_SECRET mismatch): JWT fallback applied. |
| 9 | * 4. Bridge network error: JWT fallback applied. |
| 10 | * 5. Bridge returns member, HUB_ADMIN_USER_IDS includes sub: gateway override promotes to admin. |
| 11 | */ |
| 12 | |
| 13 | import { test, describe, before, after } from 'node:test'; |
| 14 | import assert from 'node:assert/strict'; |
| 15 | import http from 'node:http'; |
| 16 | import jwt from 'jsonwebtoken'; |
| 17 | |
| 18 | const SECRET = 'test-secret-unit-suite'; |
| 19 | const ADMIN_SUB = 'google:admin-user-id'; |
| 20 | const MEMBER_SUB = 'google:member-user-id'; |
| 21 | const GATEWAY_ADMIN_SUB = 'google:gateway-admin-only-id'; |
| 22 | |
| 23 | /** |
| 24 | * Build a fake bridge server that returns a fixed role response. |
| 25 | * @param {object} opts |
| 26 | * @param {number} opts.statusCode - HTTP status code to return |
| 27 | * @param {{ role: string, may_approve_proposals: boolean }|null} opts.body - response body |
| 28 | * @param {boolean} opts.networkError - if true, destroy the socket immediately (simulate timeout) |
| 29 | */ |
| 30 | function makeBridgeServer(opts = {}) { |
| 31 | const { statusCode = 200, body = null, networkError = false } = opts; |
| 32 | const server = http.createServer((req, res) => { |
| 33 | if (networkError) { |
| 34 | req.socket.destroy(); |
| 35 | return; |
| 36 | } |
| 37 | res.writeHead(statusCode, { 'Content-Type': 'application/json' }); |
| 38 | res.end(body ? JSON.stringify(body) : ''); |
| 39 | }); |
| 40 | return server; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Minimal mock of resolveHostedActorRole logic extracted for unit testing. |
| 45 | * Mirrors the exact structure in hub/gateway/server.mjs. |
| 46 | * |
| 47 | * @param {object} opts |
| 48 | * @param {string|null} opts.bridgeUrl - BRIDGE_URL equivalent |
| 49 | * @param {string} opts.authHeader - Authorization header value |
| 50 | * @param {string} opts.jwtSecret - SESSION_SECRET |
| 51 | * @param {Set<string>} opts.adminSet - adminUserIdsSet |
| 52 | * @param {object|null} opts.hctx - hosted context from getHostedAccessContext |
| 53 | * @param {string|null} opts.bridgeRole - role the bridge returns (null = bridge non-OK) |
| 54 | * @param {number} opts.bridgeStatus - HTTP status from bridge (only used in pure mock) |
| 55 | * @param {boolean} opts.envFallback - HUB_EVALUATOR_MAY_APPROVE |
| 56 | * @param {string} opts.sub - actor's sub (getUserId result) |
| 57 | */ |
| 58 | async function resolveHostedActorRoleMock(opts) { |
| 59 | const { |
| 60 | bridgeUrl, |
| 61 | authHeader, |
| 62 | jwtSecret, |
| 63 | adminSet, |
| 64 | hctx, |
| 65 | bridgeRole, |
| 66 | bridgeStatus = 200, |
| 67 | envFallback = false, |
| 68 | sub, |
| 69 | } = opts; |
| 70 | |
| 71 | function roleForSub(s) { |
| 72 | return s && adminSet.has(s) ? 'admin' : 'member'; |
| 73 | } |
| 74 | |
| 75 | let role = 'member'; |
| 76 | let mayApproveProposals = false; |
| 77 | |
| 78 | if (hctx && typeof hctx.role === 'string') { |
| 79 | role = hctx.role; |
| 80 | if (typeof hctx.may_approve_proposals === 'boolean') { |
| 81 | mayApproveProposals = hctx.may_approve_proposals; |
| 82 | } else if (role === 'evaluator') { |
| 83 | mayApproveProposals = envFallback; |
| 84 | } |
| 85 | } else if (bridgeUrl && authHeader) { |
| 86 | let bridgeResolved = false; |
| 87 | // Simulate bridge response |
| 88 | if (bridgeStatus === 200 && bridgeRole !== null) { |
| 89 | if (bridgeRole) { |
| 90 | role = bridgeRole; |
| 91 | bridgeResolved = true; |
| 92 | } |
| 93 | mayApproveProposals = role === 'admin'; |
| 94 | } else if (bridgeStatus === 200 && bridgeRole === null) { |
| 95 | // empty/malformed response |
| 96 | } |
| 97 | // !bridgeResolved fallback — mirrors gateway code exactly |
| 98 | if (!bridgeResolved) { |
| 99 | try { |
| 100 | const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; |
| 101 | if (token && jwtSecret) { |
| 102 | const payload = jwt.verify(token, jwtSecret); |
| 103 | role = payload.role || roleForSub(payload.sub); |
| 104 | mayApproveProposals = role === 'admin' || (role === 'evaluator' && envFallback); |
| 105 | } |
| 106 | } catch (_) {} |
| 107 | } |
| 108 | } else { |
| 109 | try { |
| 110 | const token = authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; |
| 111 | if (token && jwtSecret) { |
| 112 | const payload = jwt.verify(token, jwtSecret); |
| 113 | role = payload.role || roleForSub(payload.sub); |
| 114 | mayApproveProposals = role === 'admin' || (role === 'evaluator' && envFallback); |
| 115 | } |
| 116 | } catch (_) {} |
| 117 | } |
| 118 | |
| 119 | // Gateway admin override |
| 120 | if (sub && role !== 'admin' && roleForSub(sub) === 'admin') { |
| 121 | role = 'admin'; |
| 122 | mayApproveProposals = true; |
| 123 | } |
| 124 | |
| 125 | return { role, mayApproveProposals }; |
| 126 | } |
| 127 | |
| 128 | function makeAdminToken(sub) { |
| 129 | return jwt.sign({ sub, role: 'admin' }, SECRET, { expiresIn: '1h' }); |
| 130 | } |
| 131 | |
| 132 | function makeMemberToken(sub) { |
| 133 | return jwt.sign({ sub, role: 'member' }, SECRET, { expiresIn: '1h' }); |
| 134 | } |
| 135 | |
| 136 | describe('resolveHostedActorRole — integration (mock bridge)', () => { |
| 137 | const adminSet = new Set([ADMIN_SUB, GATEWAY_ADMIN_SUB]); |
| 138 | |
| 139 | test('bridge returns admin → role is admin, canApprove', async () => { |
| 140 | const token = makeAdminToken(ADMIN_SUB); |
| 141 | const result = await resolveHostedActorRoleMock({ |
| 142 | bridgeUrl: 'http://localhost:9999', |
| 143 | authHeader: `Bearer ${token}`, |
| 144 | jwtSecret: SECRET, |
| 145 | adminSet, |
| 146 | hctx: null, |
| 147 | bridgeRole: 'admin', |
| 148 | bridgeStatus: 200, |
| 149 | sub: ADMIN_SUB, |
| 150 | }); |
| 151 | assert.equal(result.role, 'admin'); |
| 152 | assert.equal(result.mayApproveProposals, true); |
| 153 | }); |
| 154 | |
| 155 | test('bridge returns member → role is member, cannot approve (no gateway override)', async () => { |
| 156 | const token = makeMemberToken(MEMBER_SUB); |
| 157 | const result = await resolveHostedActorRoleMock({ |
| 158 | bridgeUrl: 'http://localhost:9999', |
| 159 | authHeader: `Bearer ${token}`, |
| 160 | jwtSecret: SECRET, |
| 161 | adminSet, |
| 162 | hctx: null, |
| 163 | bridgeRole: 'member', |
| 164 | bridgeStatus: 200, |
| 165 | sub: MEMBER_SUB, |
| 166 | }); |
| 167 | assert.equal(result.role, 'member'); |
| 168 | assert.equal(result.mayApproveProposals, false); |
| 169 | }); |
| 170 | |
| 171 | test('bridge 401 (SESSION_SECRET mismatch) → JWT fallback → admin JWT results in admin role', async () => { |
| 172 | const token = makeAdminToken(ADMIN_SUB); |
| 173 | const result = await resolveHostedActorRoleMock({ |
| 174 | bridgeUrl: 'http://localhost:9999', |
| 175 | authHeader: `Bearer ${token}`, |
| 176 | jwtSecret: SECRET, |
| 177 | adminSet, |
| 178 | hctx: null, |
| 179 | bridgeRole: null, // non-OK response |
| 180 | bridgeStatus: 401, |
| 181 | sub: ADMIN_SUB, |
| 182 | }); |
| 183 | // JWT has role: 'admin' so fallback returns admin |
| 184 | assert.equal(result.role, 'admin'); |
| 185 | assert.equal(result.mayApproveProposals, true); |
| 186 | }); |
| 187 | |
| 188 | test('bridge 401 → JWT fallback → member JWT results in member role (no spoofing)', async () => { |
| 189 | const token = makeMemberToken(MEMBER_SUB); |
| 190 | const result = await resolveHostedActorRoleMock({ |
| 191 | bridgeUrl: 'http://localhost:9999', |
| 192 | authHeader: `Bearer ${token}`, |
| 193 | jwtSecret: SECRET, |
| 194 | adminSet, |
| 195 | hctx: null, |
| 196 | bridgeRole: null, |
| 197 | bridgeStatus: 401, |
| 198 | sub: MEMBER_SUB, |
| 199 | }); |
| 200 | assert.equal(result.role, 'member'); |
| 201 | assert.equal(result.mayApproveProposals, false); |
| 202 | }); |
| 203 | |
| 204 | test('bridge returns member, sub in gateway HUB_ADMIN_USER_IDS → gateway override promotes to admin', async () => { |
| 205 | // GATEWAY_ADMIN_SUB is in adminSet but their JWT might say 'member' |
| 206 | const token = makeMemberToken(GATEWAY_ADMIN_SUB); |
| 207 | const result = await resolveHostedActorRoleMock({ |
| 208 | bridgeUrl: 'http://localhost:9999', |
| 209 | authHeader: `Bearer ${token}`, |
| 210 | jwtSecret: SECRET, |
| 211 | adminSet, // GATEWAY_ADMIN_SUB is in adminSet |
| 212 | hctx: null, |
| 213 | bridgeRole: 'member', |
| 214 | bridgeStatus: 200, |
| 215 | sub: GATEWAY_ADMIN_SUB, |
| 216 | }); |
| 217 | assert.equal(result.role, 'admin', 'gateway override promotes gateway admin sub'); |
| 218 | assert.equal(result.mayApproveProposals, true); |
| 219 | }); |
| 220 | |
| 221 | test('hctx present with admin role → admin (highest priority, no fallback needed)', async () => { |
| 222 | const token = makeMemberToken(MEMBER_SUB); |
| 223 | const result = await resolveHostedActorRoleMock({ |
| 224 | bridgeUrl: 'http://localhost:9999', |
| 225 | authHeader: `Bearer ${token}`, |
| 226 | jwtSecret: SECRET, |
| 227 | adminSet, |
| 228 | hctx: { role: 'admin', may_approve_proposals: true }, |
| 229 | bridgeRole: 'member', |
| 230 | sub: MEMBER_SUB, |
| 231 | }); |
| 232 | assert.equal(result.role, 'admin'); |
| 233 | assert.equal(result.mayApproveProposals, true); |
| 234 | }); |
| 235 | |
| 236 | test('no BRIDGE_URL → falls through to JWT path (no bridge call)', async () => { |
| 237 | const token = makeAdminToken(ADMIN_SUB); |
| 238 | const result = await resolveHostedActorRoleMock({ |
| 239 | bridgeUrl: null, |
| 240 | authHeader: `Bearer ${token}`, |
| 241 | jwtSecret: SECRET, |
| 242 | adminSet, |
| 243 | hctx: null, |
| 244 | bridgeRole: null, |
| 245 | sub: ADMIN_SUB, |
| 246 | }); |
| 247 | assert.equal(result.role, 'admin', 'JWT payload role used when no bridge URL'); |
| 248 | assert.equal(result.mayApproveProposals, true); |
| 249 | }); |
| 250 | |
| 251 | test('expired JWT with no bridge → role stays member (fail-closed)', async () => { |
| 252 | const token = jwt.sign({ sub: MEMBER_SUB, role: 'admin' }, SECRET, { expiresIn: '-1s' }); |
| 253 | const result = await resolveHostedActorRoleMock({ |
| 254 | bridgeUrl: null, |
| 255 | authHeader: `Bearer ${token}`, |
| 256 | jwtSecret: SECRET, |
| 257 | adminSet: new Set(), // empty admin set |
| 258 | hctx: null, |
| 259 | bridgeRole: null, |
| 260 | sub: MEMBER_SUB, |
| 261 | }); |
| 262 | // Expired token → jwt.verify throws → role stays 'member' |
| 263 | assert.equal(result.role, 'member', 'expired JWT without bridge → fail-closed member'); |
| 264 | }); |
| 265 | }); |
File History
1 commit
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb
reconcile: import GitHub-direct RBAC/OAuth/companion and ho…
Human
minor
⚠
13 hours ago