/** * Unit coverage for the remote-WebSocket forward gate. The hub must enforce the * originating user's role before forwarding a remote upgrade, because the * forwarder authenticates the forwarded connection to the remote as an * admin-gated console_session. Logs and notifications stay open to any * authenticated user; every other path is an interactive terminal (container * exec / host console) and is admin-only on the hub. */ import { describe, it, expect } from 'vitest'; import { remoteWsForwardAllowed } from '../websocket/upgradeHandler'; const viewer = { role: 'viewer' as const }; const admin = { role: 'admin' as const }; const base = { wsResolvedUser: undefined, wsApiTokenScope: null, isProxyToken: false, decoded: {} }; const LOGS = '/api/stacks/web/logs'; const NOTIF = '/ws/notifications'; const EXEC = '/ws'; const CONSOLE = '/api/system/host-console'; describe('remoteWsForwardAllowed', () => { it('allows logs and notifications for any authenticated user', () => { for (const p of [LOGS, NOTIF]) { expect(remoteWsForwardAllowed(p, { ...base, wsResolvedUser: viewer })).toBe(true); } }); it('denies interactive exec/console to every non-admin role (none holds system:console)', () => { for (const role of ['viewer', 'deployer', 'node-admin', 'auditor'] as const) { for (const p of [EXEC, CONSOLE]) { expect(remoteWsForwardAllowed(p, { ...base, wsResolvedUser: { role } })).toBe(false); } } }); it('allows interactive exec/console to an admin user session', () => { for (const p of [EXEC, CONSOLE]) { expect(remoteWsForwardAllowed(p, { ...base, wsResolvedUser: admin })).toBe(true); } }); it('gates host console on system:console, not merely role==admin (parity with handleHostConsoleWs)', async () => { // node-admin is the meaningful case: broad stack/node permissions but no // system:console, so it must be denied the remote host console. const { ROLE_PERMISSIONS } = await import('../middleware/permissions'); for (const role of ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'] as const) { const expected = ROLE_PERMISSIONS[role].includes('system:console'); expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsResolvedUser: { role } })).toBe(expected); } }); it('denies an interactive path to a node_proxy (machine) token', () => { expect(remoteWsForwardAllowed(EXEC, { ...base, isProxyToken: true, decoded: { scope: 'node_proxy' } })).toBe(false); }); it('allows an interactive path for a full-admin api token but denies a restricted scope', () => { expect(remoteWsForwardAllowed(EXEC, { ...base, wsApiTokenScope: 'full-admin', decoded: { scope: 'api_token' } })).toBe(true); expect(remoteWsForwardAllowed(EXEC, { ...base, wsApiTokenScope: 'deploy-only', decoded: { scope: 'api_token' } })).toBe(false); }); it('denies every API-token scope for Host Console while preserving full-admin for container exec', () => { expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'full-admin', decoded: { scope: 'api_token' } })).toBe(false); expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'deploy-only', decoded: { scope: 'api_token' } })).toBe(false); expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'read-only', decoded: { scope: 'api_token' } })).toBe(false); expect(remoteWsForwardAllowed(EXEC, { ...base, wsApiTokenScope: 'full-admin', decoded: { scope: 'api_token' } })).toBe(true); }); it('allows an interactive path for a pre-gated console_session token', () => { expect(remoteWsForwardAllowed(CONSOLE, { ...base, decoded: { scope: 'console_session' } })).toBe(true); }); it('treats an unknown path as interactive (admin-only)', () => { expect(remoteWsForwardAllowed('/ws/unknown', { ...base, wsResolvedUser: viewer })).toBe(false); expect(remoteWsForwardAllowed('/ws/unknown', { ...base, wsResolvedUser: admin })).toBe(true); }); });