maestro/src/bridge/console-ws-api.test.ts
oss-sync 483464597a
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (a360d15)
2026-06-09 09:19:09 +00:00

410 lines
16 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, 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<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);
});
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);
});
});