proposal-approve-rbac-fix-unit.test.mjs
167 lines 8.2 KB
Raw
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor ⚠ breaking 3 hours ago
1 /**
2 * Unit tests — proposal approve RBAC fix.
3 *
4 * Covers the structural changes to resolveHostedActorRole in hub/gateway/server.mjs:
5 * 1. Bridge unreachable (network error) → falls back to JWT payload role.
6 * 2. Bridge returns non-OK (e.g. 401, SESSION_SECRET mismatch) → falls back to JWT payload.
7 * 3. Bridge returns OK with 'admin' → normal path, no override needed.
8 * 4. Gateway admin override: HUB_ADMIN_USER_IDS trumps bridge 'member'.
9 * 5. approveProposal error path uses showToast, not a silent muted paragraph.
10 * 6. discardProposal error path uses showToast, not a silent muted paragraph.
11 */
12
13 import { test, describe } from 'node:test';
14 import assert from 'node:assert/strict';
15 import fs from 'node:fs';
16 import path from 'node:path';
17 import { fileURLToPath } from 'node:url';
18
19 const __dirname = path.dirname(fileURLToPath(import.meta.url));
20 const ROOT = path.resolve(__dirname, '..');
21
22 const SERVER_SRC = path.join(ROOT, 'hub/gateway/server.mjs');
23 const HUB_SRC = path.join(ROOT, 'web/hub/hub.js');
24
25 describe('resolveHostedActorRole — unit wiring', () => {
26 let src;
27 const load = () => {
28 if (!src) src = fs.readFileSync(SERVER_SRC, 'utf8');
29 return src;
30 };
31
32 test('resolveHostedActorRole exists in server.mjs', () => {
33 const s = load();
34 assert.ok(s.includes('async function resolveHostedActorRole'), 'function is defined');
35 });
36
37 test('bridge fallback: bridgeResolved flag is declared and checked', () => {
38 const s = load();
39 const fn = s.slice(s.indexOf('async function resolveHostedActorRole'), s.indexOf('\n}\n', s.indexOf('async function resolveHostedActorRole')));
40 assert.ok(fn.includes('bridgeResolved'), 'bridgeResolved flag present');
41 assert.ok(fn.includes('!bridgeResolved'), 'fallback check on bridgeResolved');
42 });
43
44 test('bridge fallback path: JWT verify used when bridge does not resolve', () => {
45 const s = load();
46 const fn = s.slice(s.indexOf('async function resolveHostedActorRole'), s.indexOf('\n}\n', s.indexOf('async function resolveHostedActorRole')) + 3);
47 // Confirm there are TWO jwt.verify calls: one in the existing else, one in the new fallback
48 const verifyCount = (fn.match(/jwt\.verify/g) || []).length;
49 assert.ok(verifyCount >= 2, `jwt.verify called at least twice in resolveHostedActorRole (got ${verifyCount})`);
50 });
51
52 test('gateway admin override: roleForSub check present after bridge/else branches', () => {
53 const s = load();
54 const fn = s.slice(s.indexOf('async function resolveHostedActorRole'), s.indexOf('\n}\n', s.indexOf('async function resolveHostedActorRole')) + 3);
55 assert.ok(fn.includes('roleForSub(actorSub)'), 'roleForSub used in gateway admin override');
56 assert.ok(fn.includes("role !== 'admin'"), 'override is guarded by role !== admin');
57 assert.ok(fn.includes("role = 'admin'"), 'override sets role to admin');
58 assert.ok(fn.includes('mayApproveProposals = true'), 'override sets mayApproveProposals true');
59 });
60
61 test('gateway admin override: comment explains the lockout-prevention rationale', () => {
62 const s = load();
63 const fn = s.slice(s.indexOf('async function resolveHostedActorRole'), s.indexOf('\n}\n', s.indexOf('async function resolveHostedActorRole')) + 3);
64 assert.ok(fn.includes('locked out'), 'comment explains lockout-prevention intent');
65 });
66
67 test('bridge fallback: placed inside the BRIDGE_URL branch, not outside', () => {
68 const s = load();
69 const fnStart = s.indexOf('async function resolveHostedActorRole');
70 const fnEnd = s.indexOf('\n}\n', fnStart) + 3;
71 const fn = s.slice(fnStart, fnEnd);
72 // The bridgeResolved fallback block should appear before the final gateway override block
73 const bridgeFallbackIdx = fn.indexOf('!bridgeResolved');
74 const overrideIdx = fn.indexOf('Gateway-level admin override');
75 assert.ok(bridgeFallbackIdx < overrideIdx, 'bridge fallback block appears before gateway override');
76 });
77 });
78
79 describe('hub.js approveProposal — error visibility wiring', () => {
80 let src;
81 const load = () => {
82 if (!src) src = fs.readFileSync(HUB_SRC, 'utf8');
83 return src;
84 };
85
86 test('approveProposal catch uses showToast, not appendChild', () => {
87 const s = load();
88 const fnStart = s.indexOf('async function approveProposal');
89 assert.ok(fnStart > 0, 'approveProposal function exists');
90 const fnEnd = s.indexOf('\n }\n', fnStart + 100);
91 const fn = s.slice(fnStart, fnEnd + 5);
92 assert.ok(fn.includes('showToast'), 'showToast used in approveProposal catch');
93 assert.ok(!fn.includes("className = 'muted'"), 'muted paragraph removed from approveProposal');
94 assert.ok(!fn.includes('db.appendChild'), 'appendChild pattern removed from approveProposal catch');
95 });
96
97 test('discardProposal catch uses showToast, not appendChild', () => {
98 const s = load();
99 const fnStart = s.indexOf('async function discardProposal');
100 assert.ok(fnStart > 0, 'discardProposal function exists');
101 const fnEnd = s.indexOf('\n }\n', fnStart + 100);
102 const fn = s.slice(fnStart, fnEnd + 5);
103 assert.ok(fn.includes('showToast'), 'showToast used in discardProposal catch');
104 assert.ok(!fn.includes("className = 'muted'"), 'muted paragraph removed from discardProposal');
105 assert.ok(!fn.includes('db.appendChild'), 'appendChild pattern removed from discardProposal catch');
106 });
107
108 test('approveProposal toast passes isError=true', () => {
109 const s = load();
110 const fnStart = s.indexOf('async function approveProposal');
111 const fn = s.slice(fnStart, s.indexOf('\n async function discardProposal', fnStart));
112 assert.ok(fn.includes('showToast(') && fn.includes(', true)'), 'approveProposal toast marks error=true');
113 });
114
115 test('discardProposal toast passes isError=true', () => {
116 const s = load();
117 const fnStart = s.indexOf('async function discardProposal');
118 const fn = s.slice(fnStart, s.indexOf('\n el(', fnStart));
119 assert.ok(fn.includes('showToast(') && fn.includes(', true)'), 'discardProposal toast marks error=true');
120 });
121
122 test('approveProposal still closes the panel on success (hideDetailPanelChrome present)', () => {
123 const s = load();
124 const fnStart = s.indexOf('async function approveProposal');
125 const fn = s.slice(fnStart, s.indexOf('\n async function discardProposal', fnStart));
126 assert.ok(fn.includes('hideDetailPanelChrome()'), 'panel close preserved in success path');
127 assert.ok(fn.includes('loadProposals()'), 'proposals reload preserved in success path');
128 });
129 });
130
131 describe('resolveHostedActorRole — logic invariants (structural)', () => {
132 const load = () => fs.readFileSync(SERVER_SRC, 'utf8');
133
134 test('bridge fallback only runs when bridge did not resolve (guards are symmetric)', () => {
135 const s = load();
136 const fnStart = s.indexOf('async function resolveHostedActorRole');
137 const fn = s.slice(fnStart, s.indexOf('\n}\n', fnStart) + 3);
138 // bridgeResolved = false precedes the fallback block
139 const resolvedFalse = fn.indexOf('bridgeResolved = false');
140 const resolvedTrue = fn.indexOf('bridgeResolved = true');
141 const fallback = fn.indexOf('!bridgeResolved');
142 assert.ok(resolvedFalse < fallback, 'bridgeResolved initially false before fallback');
143 assert.ok(resolvedTrue < fallback, 'bridgeResolved set true on success before fallback guard');
144 });
145
146 test('mayApproveProposals defaults to false (fail-closed)', () => {
147 const s = load();
148 const fnStart = s.indexOf('async function resolveHostedActorRole');
149 const fn = s.slice(fnStart, s.indexOf('\n}\n', fnStart) + 3);
150 // Default must be false (fail-closed)
151 assert.ok(fn.includes('let mayApproveProposals = false'), 'mayApproveProposals defaults to false');
152 });
153
154 test('role defaults to member (fail-closed)', () => {
155 const s = load();
156 const fnStart = s.indexOf('async function resolveHostedActorRole');
157 const fn = s.slice(fnStart, s.indexOf('\n}\n', fnStart) + 3);
158 assert.ok(fn.includes("let role = 'member'"), "role defaults to 'member'");
159 });
160
161 test('function returns role and mayApproveProposals', () => {
162 const s = load();
163 const fnStart = s.indexOf('async function resolveHostedActorRole');
164 const fn = s.slice(fnStart, s.indexOf('\n}\n', fnStart) + 3);
165 assert.ok(fn.includes('return { role, mayApproveProposals }'), 'return shape preserved');
166 });
167 });
File History 1 commit
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor 3 hours ago