From 5cc4566eb1d021b289b355e070c5165d90ab9dcd Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 31 Jul 2026 23:06:57 -0400 Subject: [PATCH] test(rbac): add five-role parity coverage across proxy, WebSocket, and mixed-version surfaces Adds a reusable five-role persona fixture and extends RBAC test matrix to cover proxy, WebSocket, Pilot-agent, and mixed-version transport surfaces. --- backend/src/__tests__/exec.test.ts | 48 ++++---- backend/src/__tests__/fixtures/personas.ts | 53 +++++++++ backend/src/__tests__/host-console-ws.test.ts | 31 +++-- .../operational-permission-matrix.test.ts | 49 ++++---- backend/src/__tests__/persona-fixture.test.ts | 56 +++++++++ .../proxy-pilot-agent-role-header.test.ts | 105 ++++++++++++++++ .../__tests__/proxy-role-forwarding.test.ts | 112 ++++++++++++++++-- backend/src/__tests__/rbac-regression.test.ts | 112 ++++++++++++++++++ 8 files changed, 499 insertions(+), 67 deletions(-) create mode 100644 backend/src/__tests__/fixtures/personas.ts create mode 100644 backend/src/__tests__/persona-fixture.test.ts create mode 100644 backend/src/__tests__/proxy-pilot-agent-role-header.test.ts create mode 100644 backend/src/__tests__/rbac-regression.test.ts diff --git a/backend/src/__tests__/exec.test.ts b/backend/src/__tests__/exec.test.ts index eb6940a1..633e0af5 100644 --- a/backend/src/__tests__/exec.test.ts +++ b/backend/src/__tests__/exec.test.ts @@ -321,29 +321,35 @@ describe('WebSocket upgrade - exec auth enforcement', () => { expect(code).toBe(401); }); - it('rejects WebSocket upgrade with non-admin token (403)', async () => { - // Add a non-admin user - const { DatabaseService } = await import('../services/DatabaseService'); - const bcrypt = await import('bcrypt'); - const hash = await bcrypt.hash('viewerpass', 1); - try { - DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: hash, role: 'viewer' }); - } catch { - // User may already exist - } + // All five built-in roles: container exec requires admin. Every non-admin + // role must be rejected at upgrade time by generic.ts's role === 'admin' gate. + const NON_ADMIN_EXEC_ROLES = ['viewer', 'deployer', 'node-admin', 'auditor'] as const; - const token = jwt.sign( - { username: 'viewer', role: 'viewer' }, - TEST_JWT_SECRET, - { expiresIn: '1m' }, - ); - const ws = new WebSocket(getWsUrl(), { headers: { Cookie: `sencho_token=${token}` } }); - const code = await new Promise((resolve) => { - ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); - ws.on('error', () => resolve(0)); + for (const role of NON_ADMIN_EXEC_ROLES) { + it(`rejects /ws upgrade with ${role} token (403)`, async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + const bcrypt = await import('bcrypt'); + const username = `exec_${role.replace('-', '_')}`; + const hash = bcrypt.hashSync('password123', 1); + try { + DatabaseService.getInstance().addUser({ username, password_hash: hash, role }); + } catch { + // User may already exist from a prior run in the same worker + } + + const token = jwt.sign( + { username, role }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const ws = new WebSocket(getWsUrl(), { headers: { Cookie: `sencho_token=${token}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(403); }); - expect(code).toBe(403); - }); + } it('rejects WebSocket upgrade with node_proxy token (403)', async () => { const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); diff --git a/backend/src/__tests__/fixtures/personas.ts b/backend/src/__tests__/fixtures/personas.ts new file mode 100644 index 00000000..2388910b --- /dev/null +++ b/backend/src/__tests__/fixtures/personas.ts @@ -0,0 +1,53 @@ +/** + * Reusable five-role persona fixtures for RBAC test suites. + * + * Usage (one-time setup per test file): + * const personas = seedPersonas(DatabaseService.getInstance()); + * const viewerReq = request(app).get('/api/stacks').set('Authorization', personas.viewer.bearer); + * + * Each persona carries its own smoke test so a token_version mismatch + * (seeding vs signing) surfaces as a 401 before any permission assertion. + */ +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import { TEST_JWT_SECRET } from '../helpers/setupTestDb'; +import type { UserRole } from '../../services/DatabaseService'; + +export const FIVE_ROLES: readonly UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'] as const; + +export interface Persona { + username: string; + role: UserRole; + tokenVersion: number; + bearer: string; +} + +export type PersonaMap = Record; + +/** Minimal DB interface needed by seedPersonas — avoids InstanceType issues with private constructors. */ +interface PersonaDb { + addUser(u: { username: string; password_hash: string; role: string }): number; + getUserByUsername(username: string): { username: string; role: UserRole; token_version: number } | undefined; +} + +/** Seed one user per built-in global role and return signed JWTs for all five. */ +export function seedPersonas(db: PersonaDb): PersonaMap { + const personas: Partial = {}; + + for (const role of FIVE_ROLES) { + const username = `persona-${role}`; + // Use a simple shared password since tests auth via Bearer, not login. + const passwordHash = bcrypt.hashSync('password123', 1); + db.addUser({ username, password_hash: passwordHash, role }); + const user = db.getUserByUsername(username)!; + const tv = user.token_version; + const token = jwt.sign( + { username, role, tv }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + personas[role] = { username, role, tokenVersion: tv, bearer: `Bearer ${token}` }; + } + + return personas as PersonaMap; +} diff --git a/backend/src/__tests__/host-console-ws.test.ts b/backend/src/__tests__/host-console-ws.test.ts index 973ffa91..0c7db4d1 100644 --- a/backend/src/__tests__/host-console-ws.test.ts +++ b/backend/src/__tests__/host-console-ws.test.ts @@ -65,18 +65,25 @@ describe('WebSocket upgrade - host console auth enforcement', () => { expect(await expectRejected(ws)).toBe(403); }); - it('rejects a non-admin user without system:console (403)', async () => { - const { DatabaseService } = await import('../services/DatabaseService'); - const hash = await bcrypt.hash('viewerpass', 1); - try { - DatabaseService.getInstance().addUser({ username: 'hc_viewer', password_hash: hash, role: 'viewer' }); - } catch { - // already exists from a prior run in the same worker - } - const token = jwt.sign({ username: 'hc_viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' }); - const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${token}` } }); - expect(await expectRejected(ws)).toBe(403); - }); + // All five built-in roles: only admin (system:console) is accepted on host + // console. Every other role must be rejected at upgrade time. + const NON_ADMIN_HOST_CONSOLE_ROLES = ['viewer', 'deployer', 'node-admin', 'auditor'] as const; + + for (const role of NON_ADMIN_HOST_CONSOLE_ROLES) { + it(`rejects ${role} user without system:console (403)`, async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + const username = `hc_${role.replace('-', '_')}`; + const hash = await bcrypt.hash('password123', 1); + try { + DatabaseService.getInstance().addUser({ username, password_hash: hash, role }); + } catch { + // already exists from a prior run in the same worker + } + const token = jwt.sign({ username, role }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${token}` } }); + expect(await expectRejected(ws)).toBe(403); + }); + } it('accepts a Community-tier admin', async () => { getTierSpy.mockReturnValueOnce('community'); diff --git a/backend/src/__tests__/operational-permission-matrix.test.ts b/backend/src/__tests__/operational-permission-matrix.test.ts index 972cedc9..a876ba04 100644 --- a/backend/src/__tests__/operational-permission-matrix.test.ts +++ b/backend/src/__tests__/operational-permission-matrix.test.ts @@ -3,34 +3,31 @@ import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissio import { classifyStackApiPath } from '../helpers/stackRouteAuth'; describe('operational role matrix', () => { - const expectations: Record = { - admin: { - allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'], - deny: [], - }, - 'node-admin': { - allow: ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage'], - deny: ['system:settings', 'system:users', 'system:license', 'system:registries'], - }, - deployer: { - allow: ['stack:read', 'stack:deploy'], - deny: ['stack:edit', 'stack:create', 'stack:delete', 'node:read', 'node:manage', 'system:settings'], - }, - viewer: { - allow: ['stack:read', 'node:read'], - deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:audit'], - }, - auditor: { - allow: ['stack:read', 'node:read', 'system:audit'], - deny: ['stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:manage', 'system:settings'], - }, + /** + * Lockstep guard: every role's full permission set must match exactly. + * Any addition or removal to ROLE_PERMISSIONS must update this table; + * the full-set equality catches drift that a subset check would miss. + */ + const expectedRoleActions: Record = { + admin: [ + 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', + 'node:read', 'node:manage', + 'system:settings', 'system:users', 'system:license', 'system:webhooks', + 'system:tokens', 'system:console', 'system:audit', 'system:registries', + ], + 'node-admin': [ + 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', + 'node:read', 'node:manage', + ], + deployer: ['stack:read', 'stack:deploy'], + viewer: ['stack:read', 'node:read'], + auditor: ['stack:read', 'node:read', 'system:audit'], }; - for (const [role, expected] of Object.entries(expectations)) { - it(`${role} exposes only its intended operational actions`, () => { - const actions = ROLE_PERMISSIONS[role as keyof typeof ROLE_PERMISSIONS]; - for (const action of expected.allow) expect(actions).toContain(action); - for (const action of expected.deny) expect(actions).not.toContain(action); + for (const [role, expected] of Object.entries(expectedRoleActions)) { + it(`${role} has exactly the expected permission set`, () => { + const actual = [...(ROLE_PERMISSIONS[role as keyof typeof ROLE_PERMISSIONS] ?? [])].sort(); + expect(actual).toEqual([...expected].sort()); }); } }); diff --git a/backend/src/__tests__/persona-fixture.test.ts b/backend/src/__tests__/persona-fixture.test.ts new file mode 100644 index 00000000..6c8693d5 --- /dev/null +++ b/backend/src/__tests__/persona-fixture.test.ts @@ -0,0 +1,56 @@ +/** + * Persona fixture smoke tests: every persona must authenticate and carry + * the correct permission set before any downstream test relies on it. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import { + setupTestDb, + cleanupTestDb, +} from './helpers/setupTestDb'; +import { seedPersonas, FIVE_ROLES, type PersonaMap } from './fixtures/personas'; +import { ROLE_PERMISSIONS, checkPermission } from '../middleware/permissions'; +import { DatabaseService } from '../services/DatabaseService'; + +describe('persona fixture integrity', () => { + let tmpDir: string; + let app: import('express').Express; + let personas: PersonaMap; + + beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + personas = seedPersonas(DatabaseService.getInstance()); + }); + + afterAll(() => { + cleanupTestDb(tmpDir); + }); + + for (const role of FIVE_ROLES) { + describe(`${role} persona`, () => { + it('authenticates on the auth-status endpoint', async () => { + const res = await request(app) + .get('/api/auth/status') + .set('Authorization', personas[role].bearer); + expect(res.status).toBe(200); + // Smoke: a 200 on /api/auth/status proves the JWT was accepted. + }); + + it('has the expected global permissions from ROLE_PERMISSIONS', () => { + const actions = ROLE_PERMISSIONS[role]; + expect(actions).toBeDefined(); + expect(actions).toContain('stack:read'); + }); + + it('checkPermission matches global role grants for each owned action', () => { + const p = personas[role]; + const actions = ROLE_PERMISSIONS[role]; + for (const action of actions) { + const req = { user: { username: p.username, role: p.role, userId: 1 } } as any; + expect(checkPermission(req, action)).toBe(true); + } + }); + }); + } +}); diff --git a/backend/src/__tests__/proxy-pilot-agent-role-header.test.ts b/backend/src/__tests__/proxy-pilot-agent-role-header.test.ts new file mode 100644 index 00000000..97a639ec --- /dev/null +++ b/backend/src/__tests__/proxy-pilot-agent-role-header.test.ts @@ -0,0 +1,105 @@ +/** + * Pilot-agent-mode header parity: the createRemoteProxyMiddleware code path + * for pilot_agent remotes is identical to proxy mode (the only difference is + * an empty apiToken from NodeRegistry.getProxyTarget). Stub the singleton so + * a pilot_agent node routes through a loopback capture server and assert the + * same PROXY_ROLE_HEADER is forwarded. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import http from 'http'; +import request from 'supertest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { PROXY_ROLE_HEADER } from '../services/license-headers'; +import { seedPersonas } from './fixtures/personas'; +import { DatabaseService } from '../services/DatabaseService'; + +describe('pilot-agent-mode proxy role header parity', () => { + let tmpDir: string; + let app: import('express').Express; + let server: http.Server; + let captured: http.IncomingHttpHeaders | null = null; + let pilotNodeId: number; + let personas: ReturnType; + + beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + personas = seedPersonas(DatabaseService.getInstance()); + + // Loopback capture server that advertises cross-node-rbac. + server = http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ version: '0.96.0', capabilities: ['cross-node-rbac'] })); + return; + } + captured = req.headers; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('[]'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as import('net').AddressInfo).port; + + pilotNodeId = DatabaseService.getInstance().addNode({ + name: 'pilot-header-test', + type: 'remote', + mode: 'pilot_agent', + compose_dir: '/tmp', + is_default: false, + api_url: '', + api_token: '', + }); + + // getProxyTarget returns null for pilot_agent without a live bridge. + // Stub it to return the capture-server URL with an empty apiToken — the + // exact shape a real PilotTunnelBridge produces at runtime. + const { NodeRegistry } = await import('../services/NodeRegistry'); + const registry = NodeRegistry.getInstance(); + const orig = registry.getProxyTarget.bind(registry); + vi.spyOn(registry, 'getProxyTarget').mockImplementation((nid: number) => { + if (nid === pilotNodeId) return { apiUrl: `http://127.0.0.1:${port}`, apiToken: '' }; + return orig(nid); + }); + }); + + afterAll(async () => { + vi.restoreAllMocks(); + await new Promise((resolve) => server.close(() => resolve())); + cleanupTestDb(tmpDir); + }); + + it('forwards the deployer session role through the pilot-agent proxy path', async () => { + captured = null; + const res = await request(app) + .get('/api/stacks') + .set('Authorization', personas.deployer.bearer) + .set('x-node-id', String(pilotNodeId)); + expect(res.status).toBe(200); + // The capture server must have received the request. + expect(captured).not.toBeNull(); + expect(captured?.[PROXY_ROLE_HEADER]).toBe('deployer'); + }); + + it('overwrites a smuggled admin header with the real deployer role on pilot path', async () => { + captured = null; + const res = await request(app) + .get('/api/stacks') + .set('Authorization', personas.deployer.bearer) + .set('x-node-id', String(pilotNodeId)) + .set(PROXY_ROLE_HEADER, 'admin'); // smuggled + expect(res.status).toBe(200); + expect(captured).not.toBeNull(); + expect(captured?.[PROXY_ROLE_HEADER]).toBe('deployer'); + }); + + it('forwards the admin session role through the pilot-agent proxy path', async () => { + captured = null; + const res = await request(app) + .get('/api/stacks') + .set('Authorization', personas.admin.bearer) + .set('x-node-id', String(pilotNodeId)); + expect(res.status).toBe(200); + expect(captured).not.toBeNull(); + expect(captured?.[PROXY_ROLE_HEADER]).toBe('admin'); + }); +}); diff --git a/backend/src/__tests__/proxy-role-forwarding.test.ts b/backend/src/__tests__/proxy-role-forwarding.test.ts index 2a7ea3fa..22d1df35 100644 --- a/backend/src/__tests__/proxy-role-forwarding.test.ts +++ b/backend/src/__tests__/proxy-role-forwarding.test.ts @@ -24,22 +24,38 @@ import { PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADE let tmpDir: string; let app: import('express').Express; let viewerBearer: string; +let nodeAdminBearer: string; +let auditorBearer: string; +let deployerBearer: string; const VIEWER_USER = 'proxy-role-viewer'; +const NODE_ADMIN_USER = 'proxy-role-node-admin'; +const AUDITOR_USER = 'proxy-role-auditor'; +const DEPLOYER_USER = 'proxy-role-deployer'; beforeAll(async () => { tmpDir = await setupTestDb(); ({ app } = await import('../index')); const { DatabaseService } = await import('../services/DatabaseService'); const db = DatabaseService.getInstance(); - const hash = await bcrypt.hash('password123', 1); - db.addUser({ username: VIEWER_USER, password_hash: hash, role: 'viewer' }); - const viewer = db.getUserByUsername(VIEWER_USER)!; - viewerBearer = jwt.sign( - { username: VIEWER_USER, role: 'viewer', tv: viewer.token_version }, - TEST_JWT_SECRET, - { expiresIn: '1m' }, - ); + + // Seed bearer tokens for each non-admin role, matching the original + // viewer pattern exactly to avoid drift. + const seed = (username: string, role: string): string => { + const hash = bcrypt.hashSync('password123', 1); + db.addUser({ username, password_hash: hash, role: role as any }); + const user = db.getUserByUsername(username)!; + return jwt.sign( + { username, role, tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + }; + + viewerBearer = seed(VIEWER_USER, 'viewer'); + deployerBearer = seed(DEPLOYER_USER, 'deployer'); + nodeAdminBearer = seed(NODE_ADMIN_USER, 'node-admin'); + auditorBearer = seed(AUDITOR_USER, 'auditor'); }); afterAll(() => { @@ -127,9 +143,65 @@ describe('authMiddleware - forwarded actor role (node_proxy)', () => { .set(PROXY_ROLE_HEADER, 'superadmin'); expect(res.status).toBe(200); }); + + // All five built-in roles: denied on admin-only, allowed on read-only. + const NON_ADMIN_ROLES = [ + { role: 'viewer', desc: 'viewer' }, + { role: 'deployer', desc: 'deployer' }, + { role: 'node-admin', desc: 'node-admin' }, + { role: 'auditor', desc: 'auditor' }, + ]; + + for (const { role, desc } of NON_ADMIN_ROLES) { + it(`denies admin-only route for forwarded ${desc}`, async () => { + const token = signToken({ scope: 'node_proxy' }); + const res = await request(app) + .get(ADMIN_ONLY) + .set('Authorization', `Bearer ${token}`) + .set(PROXY_ROLE_HEADER, role); + expect(res.status).toBe(403); + }); + + it(`grants read access for forwarded ${desc}`, async () => { + const token = signToken({ scope: 'node_proxy' }); + const res = await request(app) + .get(READ_ONLY) + .set('Authorization', `Bearer ${token}`) + .set(PROXY_ROLE_HEADER, role); + expect(res.status).toBe(200); + }); + + it(`applies same trust to pilot_tunnel for forwarded ${desc}`, async () => { + const token = signToken({ scope: 'pilot_tunnel' }); + const res = await request(app) + .get(ADMIN_ONLY) + .set('Authorization', `Bearer ${token}`) + .set(PROXY_ROLE_HEADER, role); + expect(res.status).toBe(403); + }); + } }); describe('Security - actor-role header cannot be smuggled by a user session', () => { + // Verify each non-admin bearer token authenticates locally before testing + // proxy paths. Catches token-version drift between seeding and signing. + // Bearer variables are set in beforeAll; resolve lazily inside each test. + const BEARER_REFS: Array<{ name: string; get: () => string }> = [ + { name: 'viewer', get: () => viewerBearer }, + { name: 'deployer', get: () => deployerBearer }, + { name: 'node-admin', get: () => nodeAdminBearer }, + { name: 'auditor', get: () => auditorBearer }, + ]; + + for (const { name, get } of BEARER_REFS) { + it(`${name} token authenticates on /api/auth/status`, async () => { + const res = await request(app) + .get('/api/auth/status') + .set('Authorization', `Bearer ${get()}`); + expect(res.status).toBe(200); + }); + } + it('ignores the role header on a cookie session (uses the DB role)', async () => { const cookie = await loginAsTestAdmin(app); // A browser session that tries to downgrade itself (or, by the same path, @@ -235,4 +307,28 @@ describe('remote proxy gateway - actor role header forwarding', () => { expect(captured?.[PROXY_DEPLOY_SOURCE_HEADER]).toBe('manual'); expect(captured?.[PROXY_DEPLOY_ACTOR_HEADER]).toBe(VIEWER_USER); }); + + // Anti-smuggling extended to the remaining non-admin roles: each persona's + // real role must be forwarded even when an attacker sets the admin header. + // The bearer variables are set in beforeAll; resolve them lazily inside each + // test so we never capture undefined at describe-registration time. + const SMUGGLE_PERSONAS: Array<{ name: string; bearerRef: () => string; role: string }> = [ + { name: 'deployer', bearerRef: () => deployerBearer, role: 'deployer' }, + { name: 'node-admin', bearerRef: () => nodeAdminBearer, role: 'node-admin' }, + { name: 'auditor', bearerRef: () => auditorBearer, role: 'auditor' }, + ]; + + for (const { name, bearerRef, role } of SMUGGLE_PERSONAS) { + it(`overwrites smuggled admin header with ${name} session role`, async () => { + captured = null; + const res = await request(app) + .get(READ_ONLY) + .set('Authorization', `Bearer ${bearerRef()}`) + .set('x-node-id', String(remoteNodeId)) + .set(PROXY_ROLE_HEADER, 'admin'); + expect(res.status).toBe(200); + expect(captured?.[PROXY_ROLE_HEADER]).toBe(role); + }); + } }); + diff --git a/backend/src/__tests__/rbac-regression.test.ts b/backend/src/__tests__/rbac-regression.test.ts new file mode 100644 index 00000000..d5d2367f --- /dev/null +++ b/backend/src/__tests__/rbac-regression.test.ts @@ -0,0 +1,112 @@ +/** + * RBAC regression coverage: MFA reset gating, SSO user permission parity, + * and scoped-evidence fail-closed on remote. + * + * Tests for last-admin protection, immediate role-change effect, and + * API-token scope isolation are already covered by users-rbac.test.ts, + * api-tokens.test.ts, and api-token-ws-scope.test.ts respectively. + * + * Single describe + single setupTestDb()/cleanupTestDb() pair because the + * DatabaseService singleton is lazy-constructed on first getInstance() and + * never re-initializes; a second setupTestDb() after a first cleanupTestDb() + * would reuse a stale handle pointing at a deleted file, tripping + * SQLITE_READONLY_DBMOVED on Linux. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { + setupTestDb, + cleanupTestDb, + TEST_JWT_SECRET, +} from './helpers/setupTestDb'; +import { checkPermission } from '../middleware/permissions'; +import { DatabaseService } from '../services/DatabaseService'; + +describe('RBAC regression coverage', () => { + let tmpDir: string; + let app: import('express').Express; + + beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + }); + + afterAll(() => { + cleanupTestDb(tmpDir); + }); + + // ── MFA reset gating ──────────────────────────────────────────── + // POST /:id/mfa/reset requires system:users (admin only). + const TARGET_ID = 1; // Baseline admin is id 1, seeded by globalSetup. + + it('rejects unauthenticated MFA reset (401)', async () => { + const res = await request(app).post(`/api/users/${TARGET_ID}/mfa/reset`); + expect(res.status).toBe(401); + }); + + it('rejects non-admin from resetting MFA (403)', async () => { + const db = DatabaseService.getInstance(); + const bcrypt = await import('bcrypt'); + const hash = await bcrypt.default.hash('password123', 1); + db.addUser({ username: 'mfa-viewer', password_hash: hash, role: 'viewer' }); + const user = db.getUserByUsername('mfa-viewer')!; + const token = jwt.sign( + { username: 'mfa-viewer', role: 'viewer', tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const res = await request(app) + .post(`/api/users/${TARGET_ID}/mfa/reset`) + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(403); + }); + + // ── SSO user permission parity ────────────────────────────────── + + it('grants same permissions as a local user of the same role', () => { + const db = DatabaseService.getInstance(); + db.addUser({ + username: 'sso-node-admin', + password_hash: '$sso$fake', + role: 'node-admin', + auth_provider: 'oidc_google', + provider_id: 'google-456', + email: 'sso-na@test.com', + }); + const ssoUser = db.getUserByUsername('sso-node-admin')!; + + // permissions.ts never branches on auth_provider; only role matters. + const req = { + user: { username: ssoUser.username, role: ssoUser.role, userId: ssoUser.id }, + } as any; + + expect(checkPermission(req, 'stack:read')).toBe(true); + expect(checkPermission(req, 'stack:edit')).toBe(true); + expect(checkPermission(req, 'stack:deploy')).toBe(true); + expect(checkPermission(req, 'node:manage')).toBe(true); + expect(checkPermission(req, 'system:settings')).toBe(false); + expect(checkPermission(req, 'system:users')).toBe(false); + }); + + // ── Scoped evidence fail-closed ───────────────────────────────── + + it('denies a scoped-only user when scopedStackEvidence is absent', () => { + const remoteReq = { + user: { username: 'node-proxy', role: 'viewer', userId: 0 }, + scopedStackEvidence: undefined, + } as any; + expect(checkPermission(remoteReq, 'stack:edit', 'stack', 'web')).toBe(false); + }); + + it('allows the same action when evidence is present and matches', () => { + const remoteReq = { + user: { username: 'node-proxy', role: 'viewer', userId: 0 }, + scopedStackEvidence: { + stackName: 'web', + actions: new Set(['stack:edit', 'stack:deploy']), + }, + } as any; + expect(checkPermission(remoteReq, 'stack:edit', 'stack', 'web')).toBe(true); + }); +});