proposal-approve-rbac-fix-data-integrity.test.mjs
195 lines 8.0 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days 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(
149 src.indexOf('Gateway-level admin override'),
150 src.indexOf('return { role, mayApproveProposals, isMcpAccess: false }'),
151 );
152 assert.ok(overrideSection.includes("role !== 'admin'"), 'Override is guarded by role !== admin (no downgrade possible)');
153 assert.ok(
154 overrideSection.includes('mayApplyAdminAllowlistOverride(bearerPayload)'),
155 'Override is gated so mcp_access never inherits HUB_ADMIN_USER_IDS (SEC-KN-3)',
156 );
157 });
158 });
159
160 describe('Data integrity: source-code consistency', () => {
161 const load = () => fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
162
163 test('resolveHostedActorRole return shape includes isMcpAccess (SEC-KN-3)', () => {
164 const src = load();
165 // SEC-KN-3 extended the return with isMcpAccess; SEC-SEAM-1 also returns payload for sessionBound.
166 assert.ok(
167 src.includes('return { role, mayApproveProposals, isMcpAccess: false, payload: bearerPayload }'),
168 'Final return includes isMcpAccess: false',
169 );
170 assert.ok(
171 src.includes('return { role, mayApproveProposals, isMcpAccess: true, payload: bearerPayload }'),
172 'mcp_access early-return includes isMcpAccess: true',
173 );
174 const fnStart = src.indexOf('async function resolveHostedActorRole');
175 const fnEnd = src.indexOf('\nasync function assertHostedProposalApproveDiscard', fnStart);
176 const fn = src.slice(fnStart, fnEnd > fnStart ? fnEnd : undefined);
177 const returnLine = fn.slice(fn.lastIndexOf('return {'), fn.lastIndexOf('return {') + 90);
178 assert.ok(!returnLine.includes('bridgeResolved'), 'bridgeResolved not leaked in return');
179 assert.ok(!returnLine.includes('actorSub'), 'actorSub not leaked in return');
180 });
181
182 test('bridgeResolved flag starts false and only transitions to true on valid bridge data', () => {
183 const src = load();
184 const fnStart = src.indexOf('async function resolveHostedActorRole');
185 const fn = src.slice(fnStart, src.indexOf('\n}\n', fnStart) + 3);
186 const bridgeBlock = fn.slice(fn.indexOf('else if (BRIDGE_URL'), fn.indexOf('} else {'));
187 // bridgeResolved = false must precede bridgeResolved = true
188 const falseIdx = bridgeBlock.indexOf('bridgeResolved = false');
189 const trueIdx = bridgeBlock.indexOf('bridgeResolved = true');
190 assert.ok(falseIdx < trueIdx, 'bridgeResolved transitions false→true in correct order');
191 // bridgeResolved = true is inside the roleRes.ok block
192 const okBlock = bridgeBlock.slice(bridgeBlock.indexOf('if (roleRes.ok)'));
193 assert.ok(okBlock.includes('bridgeResolved = true'), 'bridgeResolved = true only inside roleRes.ok');
194 });
195 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago