import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Database from 'better-sqlite3'; import express from 'express'; import request from 'supertest'; import { decideAccess, handleConsoleSocket, createConsoleStatusRouter, createConsoleSessionRouter } from './console-ws-api.js'; import { runMigrations } from '../db/migrate.js'; import { createAccessResolver } from '../ssh/access.js'; import { createGrantsRepo } from '../ssh/grants-repo.js'; import type { SshConnection } from '../ssh/connection-repo.js'; describe('decideAccess', () => { const baseTask = { id: 't1', ownerId: 'u1', visibility: 'private', pieceName: 'ssh-console' }; it('rejects unauthenticated', () => { const r = decideAccess({ user: null, task: baseTask, session: null, accessAllowed: false }); expect(r.allowed).toBe(false); expect((r as any).reason).toBe('unauthenticated'); }); it('rejects when task not visible', () => { const r = decideAccess({ user: { id: 'u2', role: 'user' } as any, task: null, session: null, accessAllowed: false }); expect(r.allowed).toBe(false); expect((r as any).reason).toBe('task_not_visible'); }); it('rejects when no active session', () => { const r = decideAccess({ user: { id: 'u1', role: 'user' } as any, task: baseTask, session: null, accessAllowed: true }); expect(r.allowed).toBe(false); expect((r as any).reason).toBe('no_session'); }); it('rejects when SSH access denied', () => { const session = { connectionId: 'c1' } as any; const r = decideAccess({ user: { id: 'u1', role: 'user' } as any, task: baseTask, session, accessAllowed: false }); expect(r.allowed).toBe(false); expect((r as any).reason).toBe('no_grant'); }); it('owner gets canWrite=true', () => { const session = { connectionId: 'c1' } as any; const r = decideAccess({ user: { id: 'u1', role: 'user' } as any, task: baseTask, session, accessAllowed: true }); expect(r.allowed).toBe(true); if (r.allowed) expect(r.canWrite).toBe(true); }); it('non-owner with task visibility gets canWrite=false', () => { const session = { connectionId: 'c1' } as any; const r = decideAccess({ user: { id: 'other', role: 'user' } as any, task: { ...baseTask, visibility: 'org' } as any, session, accessAllowed: true }); expect(r.allowed).toBe(true); if (r.allowed) expect(r.canWrite).toBe(false); }); it('admin always canWrite=true', () => { const session = { connectionId: 'c1' } as any; const r = decideAccess({ user: { id: 'admin', role: 'admin' } as any, task: baseTask, session, accessAllowed: true }); expect(r.allowed).toBe(true); if (r.allowed) expect(r.canWrite).toBe(true); }); }); class FakeWS extends EventEmitter { readyState = 1; // OPEN OPEN = 1; sent: Array<{ kind: 'text' | 'binary'; data: any }> = []; send(data: any, opts?: { binary?: boolean }) { if (opts?.binary) this.sent.push({ kind: 'binary', data }); else this.sent.push({ kind: 'text', data: JSON.parse(data) }); } } function fakeSessionForWs() { return { cols: 80, rows: 24, connectionId: 'c1', scrollbackBytes: () => Buffer.alloc(0), onOutput: (_cb: any) => () => {}, write: vi.fn(), resize: vi.fn(), addViewer: vi.fn(() => () => {}), listViewers: vi.fn(() => []), } as any; } describe('handleConsoleSocket', () => { it('drops human input that fails deny-list and emits notice', () => { const ws = new FakeWS(); const session = fakeSessionForWs(); handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] }); ws.emit('message', Buffer.from('rm -rf /\n'), true); expect(session.write).not.toHaveBeenCalled(); const notice = ws.sent.find((s) => s.kind === 'text' && s.data.type === 'notice'); expect(notice).toBeDefined(); expect((notice as any).data.severity).toBe('error'); }); it('forwards safe human input to session.write', () => { const ws = new FakeWS(); const session = fakeSessionForWs(); handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] }); ws.emit('message', Buffer.from('uptime\n'), true); expect(session.write).toHaveBeenCalled(); const arg = session.write.mock.calls[0][0] as Buffer; expect(arg.toString()).toBe('uptime\n'); }); it('rejects input when canWrite=false', () => { const ws = new FakeWS(); const session = fakeSessionForWs(); handleConsoleSocket(ws as any, session, { id: 'u2', role: 'user' }, false, { deny: [], allow: [] }); ws.emit('message', Buffer.from('uptime\n'), true); expect(session.write).not.toHaveBeenCalled(); const notice = ws.sent.find((s) => s.kind === 'text' && s.data.type === 'notice'); expect(notice).toBeDefined(); expect((notice as any).data.severity).toBe('warn'); }); it('handles resize text frame when canWrite=true', () => { const ws = new FakeWS(); const session = fakeSessionForWs(); handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] }); ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: 100, rows: 40 })), false); expect(session.resize).toHaveBeenCalledWith(100, 40); }); it('ignores resize text frame when canWrite=false', () => { const ws = new FakeWS(); const session = fakeSessionForWs(); handleConsoleSocket(ws as any, session, { id: 'u2', role: 'user' }, false, { deny: [], allow: [] }); ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: 100, rows: 40 })), false); expect(session.resize).not.toHaveBeenCalled(); }); it('registers a ViewerHandle with the session on attach', () => { const ws = new FakeWS(); const session = fakeSessionForWs(); handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] }); expect(session.addViewer).toHaveBeenCalledTimes(1); const handle = (session.addViewer as any).mock.calls[0][0]; expect(handle.userId).toBe('u1'); expect(typeof handle.close).toBe('function'); }); it('viewer.close() sends a close message and ws.close(1008, reason)', () => { const ws = new FakeWS(); (ws as any).close = vi.fn(); const session = fakeSessionForWs(); handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] }); const handle = (session.addViewer as any).mock.calls[0][0]; handle.close('access_revoked'); const closeMsg = ws.sent.find((s) => s.kind === 'text' && s.data.type === 'close'); expect(closeMsg).toBeDefined(); expect((closeMsg as any).data.reason).toBe('access_revoked'); expect((ws as any).close).toHaveBeenCalledWith(1008, 'access_revoked'); }); it('viewer unsubscribes from session on ws close', () => { const ws = new FakeWS(); const unsubViewer = vi.fn(); const session = fakeSessionForWs(); (session.addViewer as any).mockReturnValue(unsubViewer); handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] }); ws.emit('close'); expect(unsubViewer).toHaveBeenCalled(); }); }); // Regression: PR fixing "wss error for non-admin owner of task with piece-specific grant". // Documents the contract that the WS upgrade access check MUST pass the // task's pieceName to accessResolver so piece-specific grants match. // Before fix: server.ts resolveSshAccess hardcoded pieceName: '' → all // piece-specific grants silently failed (no_grant) even when one existed. describe('regression: piece-specific grant matching via accessResolver', () => { let tmpRoot: string; let db: Database.Database; const CONN_ID = 'conn-global-1'; const USER_ID = 'user-non-admin-1'; const PIECE = 'ssh-console'; beforeEach(() => { tmpRoot = mkdtempSync(join(tmpdir(), 'console-ws-regression-')); db = new Database(join(tmpRoot, 'test.db')); runMigrations(db); // Insert a global connection (ownerId=NULL) so the owner branch in // access.ts doesn't short-circuit — forces grant lookup. db.prepare(` INSERT INTO ssh_connections (id, owner_id, label, host, port, username, private_key_enc, remote_path_prefix, enabled, created_at, updated_at) VALUES (?, NULL, 'global', 'host.example', 22, 'u', X'00', '/srv', 1, datetime('now'), datetime('now')) `).run(CONN_ID); // Insert a piece-specific grant for the user. const grantsRepo = createGrantsRepo(db); grantsRepo.create({ connectionId: CONN_ID, subjectType: 'user', subjectId: USER_ID, pieceName: PIECE, appliesToAllPieces: false, grantedByUserId: 'admin-1', reason: 'regression test', }); }); afterEach(() => { db.close(); rmSync(tmpRoot, { recursive: true, force: true }); }); function connection(): SshConnection { return { id: CONN_ID, ownerId: null, label: 'global', host: 'host.example', port: 22, username: 'u', privateKeyEnc: Buffer.alloc(0), passphraseEnc: null, keyVersion: 1, keyFingerprint: null, hostKeyType: null, hostKeyB64: null, hostKeyFingerprint: null, hostKeyRecordedAt: null, hostKeyVerifiedAt: null, hostKeyPending: false, hostKeyPendingB64: null, hostKeyPendingFingerprint: null, hostKeyPendingToken: null, hostKeyPendingSource: null, commandDenyPatterns: null, commandAllowPatterns: null, remotePathPrefix: '/srv', enabled: true, allowRemoteUnrestricted: false, allowPrivateAddresses: false, createdAt: '', updatedAt: '', } as unknown as SshConnection; } it('access GRANTED when pieceName matches the grant (the fix)', () => { const grants = createGrantsRepo(db); const resolver = createAccessResolver(grants, { adminBypassesGrants: true }); const decision = resolver.resolveAccess({ connection: connection(), userId: USER_ID, isAdmin: false, pieceName: PIECE, // <-- the fix passes the task's actual pieceName orgIds: [], }); expect(decision.allowed).toBe(true); expect((decision as any).via).toBe('grant'); }); it('access DENIED when pieceName is empty (the original bug)', () => { const grants = createGrantsRepo(db); const resolver = createAccessResolver(grants, { adminBypassesGrants: true }); const decision = resolver.resolveAccess({ connection: connection(), userId: USER_ID, isAdmin: false, pieceName: '', // <-- pre-fix server.ts hardcoded this; grant never matches orgIds: [], }); expect(decision.allowed).toBe(false); expect((decision as any).reason).toBe('no_grant'); }); it('access DENIED when pieceName differs from the grant', () => { const grants = createGrantsRepo(db); const resolver = createAccessResolver(grants, { adminBypassesGrants: true }); const decision = resolver.resolveAccess({ connection: connection(), userId: USER_ID, isAdmin: false, pieceName: 'unrelated-piece', orgIds: [], }); expect(decision.allowed).toBe(false); }); }); describe('createConsoleStatusRouter', () => { // Issue #347 regression: the App.tsx-side poller fires // GET /api/local/tasks/:taskId/console/status every 5 seconds for // the currently-selected local task. Returning 404 when the task is // missing / not-visible logged an unsuppressible network error in // the browser DevTools console on every tick. The router now returns // 200 active=false instead, matching the no-session shape. function buildApp(opts: { resolveTask?: (id: string, user: any) => Promise; registry?: { get: (id: string) => any }; user?: any; }) { const app = express(); if (opts.user) { app.use((req, _res, next) => { (req as any).user = opts.user; next(); }); } app.use('/api', createConsoleStatusRouter({ registry: (opts.registry ?? { get: () => null }) as any, requireAuth: (_req: any, _res: any, next: any) => next(), resolveTask: opts.resolveTask ?? (async () => null), })); return app; } it('returns 200 active=false when task is not visible to the user (was 404)', async () => { const app = buildApp({ user: { id: 'alice', role: 'user' }, resolveTask: async () => null, // not visible }); const res = await request(app).get('/api/local/tasks/182/console/status'); expect(res.status).toBe(200); expect(res.body).toEqual({ active: false }); }); it('returns 200 active=false when task exists but no SSH session is open', async () => { const app = buildApp({ user: { id: 'alice', role: 'user' }, resolveTask: async () => ({ id: 't1' }), registry: { get: () => null }, }); const res = await request(app).get('/api/local/tasks/t1/console/status'); expect(res.status).toBe(200); expect(res.body).toEqual({ active: false }); }); it('returns 200 active=true with session metadata when session is live', async () => { const now = Date.now(); const app = buildApp({ user: { id: 'alice', role: 'user' }, resolveTask: async () => ({ id: 't1' }), registry: { get: () => ({ connectionId: 'conn-1', startedAt: now - 60_000, lastActivityAt: now, cols: 120, rows: 30, }) }, }); const res = await request(app).get('/api/local/tasks/t1/console/status'); expect(res.status).toBe(200); expect(res.body.active).toBe(true); expect(res.body.connection_id).toBe('conn-1'); expect(res.body.cols).toBe(120); }); it('still returns 401 when there is no authenticated user', async () => { const app = buildApp({}); // no user middleware const res = await request(app).get('/api/local/tasks/t1/console/status'); expect(res.status).toBe(401); }); it('no-auth (authActive=false): synthesizes a local admin user instead of 401', async () => { let seenUser: any; const app = express(); app.use('/api', createConsoleStatusRouter({ registry: { get: () => null } as any, requireAuth: (_req: any, _res: any, next: any) => next(), resolveTask: async (_id: string, user: any) => { seenUser = user; return null; }, authActive: false, })); const res = await request(app).get('/api/local/tasks/t1/console/status'); // 200 active=false (task not visible shape), NOT 401 — the synthetic user // cleared the auth guard. admin role makes null-owner no-auth tasks visible. expect(res.status).toBe(200); expect(seenUser).toEqual({ id: 'local', role: 'admin', orgIds: [] }); }); }); describe('createConsoleSessionRouter', () => { it('no-auth (authActive=false): synthesizes a local admin user instead of 401', async () => { let seenUser: any; const app = express(); app.use(express.json()); app.use('/api', createConsoleSessionRouter({ sub: {} as any, preflight: {} as any, requireAuth: (_req: any, _res: any, next: any) => next(), resolveTask: async (_id: string, user: any) => { seenUser = user; return null; }, authActive: false, })); const res = await request(app) .post('/api/local/tasks/t1/console/session') .send({ connection_id: 'c1' }); // 404 task_not_found (resolveTask returned null), NOT 401 — proves the // synthetic user passed the auth guard before task resolution. expect(res.status).toBe(404); expect(seenUser).toEqual({ id: 'local', role: 'admin', orgIds: [] }); }); it('still returns 401 when auth is active and there is no user', async () => { const app = express(); app.use(express.json()); app.use('/api', createConsoleSessionRouter({ sub: {} as any, preflight: {} as any, requireAuth: (_req: any, _res: any, next: any) => next(), resolveTask: async () => null, })); const res = await request(app) .post('/api/local/tasks/t1/console/session') .send({ connection_id: 'c1' }); expect(res.status).toBe(401); }); });