proposal-approve-rbac-fix-integration.test.mjs
265 lines 9.1 KB
Raw
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