proposal-approve-rbac-fix-data-integrity.test.mjs
180 lines 7.4 KB
Raw
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor ⚠ breaking 13 hours ago
1 /**
2 * Data-integrity tests — proposal approve RBAC fix.
3 *
4 * Verifies that role data flowing through the fix is:
5 * - Never mutated unexpectedly between call sites.
6 * - Correctly typed (string 'admin'/'member', boolean mayApproveProposals).
7 * - Consistent with the input (bridge role wins over JWT when bridge resolves).
8 * - Bridge data only overwrites default when explicitly set (no undefined bleed).
9 */
10
11 import { test, describe } from 'node:test';
12 import assert from 'node:assert/strict';
13 import jwt from 'jsonwebtoken';
14 import fs from 'node:fs';
15 import path from 'node:path';
16 import { fileURLToPath } from 'node:url';
17
18 const __dirname = path.dirname(fileURLToPath(import.meta.url));
19 const ROOT = path.resolve(__dirname, '..');
20
21 const SECRET = 'data-integrity-secret';
22
23 function makeToken(sub, role) {
24 return jwt.sign({ sub, role }, SECRET, { expiresIn: '1h' });
25 }
26
27 async function resolveRole({ bridgeRole, bridgeStatus, bridgeMayApprove, adminSet, token, sub }) {
28 function roleForSub(s) { return adminSet.has(s) ? 'admin' : 'member'; }
29 let role = 'member';
30 let mayApproveProposals = false;
31 let bridgeResolved = false;
32
33 if (bridgeStatus === 200 && bridgeRole) {
34 role = bridgeRole;
35 bridgeResolved = true;
36 if (typeof bridgeMayApprove === 'boolean') {
37 mayApproveProposals = bridgeMayApprove;
38 } else {
39 mayApproveProposals = role === 'admin';
40 }
41 }
42
43 if (!bridgeResolved) {
44 try {
45 const payload = jwt.verify(token, SECRET);
46 role = payload.role || roleForSub(payload.sub);
47 mayApproveProposals = role === 'admin';
48 } catch (_) {}
49 }
50
51 if (sub && role !== 'admin' && roleForSub(sub) === 'admin') {
52 role = 'admin';
53 mayApproveProposals = true;
54 }
55
56 return { role, mayApproveProposals };
57 }
58
59 describe('Data integrity: role typing and values', () => {
60 const VALID_ROLES = new Set(['admin', 'member', 'evaluator', 'editor', 'viewer']);
61
62 test('role is always a string', async () => {
63 const cases = [
64 { bridgeRole: 'admin', bridgeStatus: 200 },
65 { bridgeRole: 'member', bridgeStatus: 200 },
66 { bridgeRole: null, bridgeStatus: 401 },
67 { bridgeRole: null, bridgeStatus: 500 },
68 ];
69 for (const c of cases) {
70 const result = await resolveRole({
71 ...c, bridgeMayApprove: undefined,
72 adminSet: new Set(),
73 token: makeToken('google:user', 'member'),
74 sub: 'google:user',
75 });
76 assert.equal(typeof result.role, 'string', `role is string for bridge status ${c.bridgeStatus}`);
77 }
78 });
79
80 test('mayApproveProposals is always boolean', async () => {
81 const cases = [
82 { bridgeRole: 'admin', bridgeStatus: 200 },
83 { bridgeRole: 'member', bridgeStatus: 200 },
84 { bridgeRole: null, bridgeStatus: 401 },
85 ];
86 for (const c of cases) {
87 const result = await resolveRole({
88 ...c, bridgeMayApprove: undefined,
89 adminSet: new Set(),
90 token: makeToken('google:user', 'member'),
91 sub: 'google:user',
92 });
93 assert.equal(typeof result.mayApproveProposals, 'boolean',
94 `mayApproveProposals is boolean for bridge status ${c.bridgeStatus}`);
95 }
96 });
97
98 test('bridge admin role is not downgraded by JWT fallback', async () => {
99 // Bridge says admin → bridgeResolved=true → no fallback
100 const result = await resolveRole({
101 bridgeRole: 'admin', bridgeStatus: 200, bridgeMayApprove: true,
102 adminSet: new Set(),
103 token: makeToken('google:user', 'member'), // JWT says member
104 sub: 'google:user',
105 });
106 assert.equal(result.role, 'admin', 'bridge admin not downgraded by member JWT');
107 assert.equal(result.mayApproveProposals, true);
108 });
109
110 test('bridge member role is not upgraded by member JWT', async () => {
111 // Bridge says member, JWT also says member
112 const result = await resolveRole({
113 bridgeRole: 'member', bridgeStatus: 200, bridgeMayApprove: false,
114 adminSet: new Set(),
115 token: makeToken('google:user', 'admin'), // JWT says admin — should not upgrade
116 sub: 'google:user',
117 });
118 // Bridge resolved, so JWT fallback does NOT run
119 assert.equal(result.role, 'member', 'bridge member not upgraded by admin JWT when bridge resolves');
120 });
121
122 test('undefined bridge role does not set role to undefined', async () => {
123 // Bridge OK but returns empty role (data integrity: undefined must not replace default)
124 const result = await resolveRole({
125 bridgeRole: '', bridgeStatus: 200, // empty string = falsy = bridgeResolved stays false
126 adminSet: new Set(),
127 token: makeToken('google:user', 'member'),
128 sub: 'google:user',
129 });
130 assert.notEqual(result.role, undefined, 'role is never undefined');
131 assert.notEqual(result.role, '', 'role is never empty string');
132 assert.equal(typeof result.role, 'string', 'role is always a string');
133 });
134
135 test('gateway override only upgrades, never downgrades', async () => {
136 const adminSet = new Set(['google:admin-sub']);
137 // Bridge says admin → override should not change it (already admin)
138 const alreadyAdmin = await resolveRole({
139 bridgeRole: 'admin', bridgeStatus: 200, bridgeMayApprove: true,
140 adminSet,
141 token: makeToken('google:admin-sub', 'admin'),
142 sub: 'google:admin-sub',
143 });
144 assert.equal(alreadyAdmin.role, 'admin');
145 // Override condition is `role !== 'admin'` so it's a no-op when already admin
146 // Verify the condition structure
147 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
148 const overrideSection = src.slice(src.indexOf('Gateway-level admin override'), src.indexOf('return { role, mayApproveProposals }'));
149 assert.ok(overrideSection.includes("role !== 'admin'"), 'Override is guarded by role !== admin (no downgrade possible)');
150 });
151 });
152
153 describe('Data integrity: source-code consistency', () => {
154 const load = () => fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
155
156 test('resolveHostedActorRole return shape is unchanged: { role, mayApproveProposals }', () => {
157 const src = load();
158 const fnStart = src.indexOf('async function resolveHostedActorRole');
159 const fn = src.slice(fnStart, src.indexOf('\n}\n', fnStart) + 3);
160 assert.ok(fn.includes('return { role, mayApproveProposals }'), 'Return shape preserved');
161 // No extra fields added
162 const returnLine = fn.slice(fn.lastIndexOf('return {'), fn.lastIndexOf('return {') + 50);
163 assert.ok(!returnLine.includes('bridgeResolved'), 'bridgeResolved not leaked in return');
164 assert.ok(!returnLine.includes('actorSub'), 'actorSub not leaked in return');
165 });
166
167 test('bridgeResolved flag starts false and only transitions to true on valid bridge data', () => {
168 const src = load();
169 const fnStart = src.indexOf('async function resolveHostedActorRole');
170 const fn = src.slice(fnStart, src.indexOf('\n}\n', fnStart) + 3);
171 const bridgeBlock = fn.slice(fn.indexOf('else if (BRIDGE_URL'), fn.indexOf('} else {'));
172 // bridgeResolved = false must precede bridgeResolved = true
173 const falseIdx = bridgeBlock.indexOf('bridgeResolved = false');
174 const trueIdx = bridgeBlock.indexOf('bridgeResolved = true');
175 assert.ok(falseIdx < trueIdx, 'bridgeResolved transitions false→true in correct order');
176 // bridgeResolved = true is inside the roleRes.ok block
177 const okBlock = bridgeBlock.slice(bridgeBlock.indexOf('if (roleRes.ok)'));
178 assert.ok(okBlock.includes('bridgeResolved = true'), 'bridgeResolved = true only inside roleRes.ok');
179 });
180 });
File History 1 commit
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor 13 hours ago