357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
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 } 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<any>;
|
|
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);
|
|
});
|
|
});
|