Files
maestro/src/engine/tools/ssh.test.ts
T

652 lines
25 KiB
TypeScript

/**
* Unit tests for the SSH tool dispatcher (engine/tools/ssh.ts).
*
* Strategy: bring up an in-memory SQLite + real repos (connection / audit /
* abuse / grants) so the audit + abuse + access decisions are exercised end-to-
* end, but stub the session primitives (sshExec / sshUpload / sshDownload) so
* no real SSH server is needed. The Phase 3 ssh-session tests already cover
* the session module; here we only verify the 12-step orchestration.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { promises as fsp } from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { runMigrations } from '../../db/migrate.js';
import { createConnectionRepo, type CreateConnectionInput, type SshConnectionRepo } from '../../ssh/connection-repo.js';
import { createGrantsRepo, type SshGrantsRepo } from '../../ssh/grants-repo.js';
import { createAuditRepo, type SshAuditRepo } from '../../ssh/audit-repo.js';
import { createAbuseRepo, type SshAbuseRepo } from '../../ssh/abuse-repo.js';
import { createAccessResolver } from '../../ssh/access.js';
import { SshSessionError } from '../../ssh/session.js';
import { SSH_DEFAULTS, type SshRuntimeConfig } from '../../ssh/config.js';
import type { MaintenanceController } from '../../ssh/maintenance.js';
import type { ExecArgs, UploadArgs, DownloadArgs, SessionHooks } from '../../ssh/session.js';
import {
setSshSubsystem,
type SshSubsystem,
TOOL_DEFS,
} from './ssh.js';
import { executeTool } from './index.js';
import type { ToolContext } from './core.js';
const VALID_KEY = 'a'.repeat(64);
function bootstrapDb(): Database.Database {
process.env.MCP_ENCRYPTION_KEY = VALID_KEY;
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, role TEXT);`);
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
runMigrations(db);
db.prepare(`INSERT INTO users (id, role) VALUES (?, ?), (?, ?), (?, ?)`).run(
'alice', 'member',
'bob', 'member',
'admin1', 'admin',
);
return db;
}
function makeConfig(overrides: Partial<SshRuntimeConfig> = {}): SshRuntimeConfig {
return { ...SSH_DEFAULTS, ...overrides };
}
function makeMaintenance(active = false): MaintenanceController {
return {
isActive: () => active,
snapshot: () => ({ active, reason: active ? 'test' : undefined }),
enter: () => undefined,
exit: () => undefined,
};
}
interface Stubs {
execImpl?: (args: ExecArgs, hooks: SessionHooks) => Promise<ReturnType<SshSubsystem['sshExec']> extends Promise<infer R> ? R : never>;
uploadImpl?: (args: UploadArgs, hooks: SessionHooks) => Promise<ReturnType<SshSubsystem['sshUpload']> extends Promise<infer R> ? R : never>;
downloadImpl?: (args: DownloadArgs, hooks: SessionHooks) => Promise<ReturnType<SshSubsystem['sshDownload']> extends Promise<infer R> ? R : never>;
}
function makeSubsystem(opts: {
db: Database.Database;
config?: SshRuntimeConfig;
maintenance?: MaintenanceController;
stubs?: Stubs;
userAccess?: Record<string, { isAdmin: boolean; orgIds: string[] }>;
}): { sub: SshSubsystem; repos: { conn: SshConnectionRepo; audit: SshAuditRepo; abuse: SshAbuseRepo; grants: SshGrantsRepo } } {
const conn = createConnectionRepo(opts.db);
const grants = createGrantsRepo(opts.db);
const audit = createAuditRepo(opts.db);
const abuse = createAbuseRepo(opts.db, {
windowMinutes: 10,
failureThreshold: 5,
lockMinutes: 30,
});
const access = createAccessResolver(grants, { adminBypassesGrants: true });
const sub: SshSubsystem = {
connectionRepo: conn,
auditRepo: audit,
abuseRepo: abuse,
accessResolver: access,
decryptKeyMaterial: (_ownerId, blob) => Buffer.from(blob), // identity (we stub session anyway)
decryptPassphrase: (_ownerId, blob) => (blob ? Buffer.from(blob) : null),
getUserAccess: (userId) => opts.userAccess?.[userId] ?? { isAdmin: false, orgIds: [] },
sshExec: opts.stubs?.execImpl
? opts.stubs.execImpl
: async () => ({
outputJson: JSON.stringify({ stdout: 'ok', exit_code: 0, untrusted: true }),
exitCode: 0,
durationMs: 12,
hostFingerprint: 'SHA256:fake',
}),
sshUpload: opts.stubs?.uploadImpl
? opts.stubs.uploadImpl
: async () => ({ bytes: 4, durationMs: 5, hostFingerprint: 'SHA256:fake' }),
sshDownload: opts.stubs?.downloadImpl
? opts.stubs.downloadImpl
: async () => ({ bytes: 4, durationMs: 5, hostFingerprint: 'SHA256:fake' }),
maintenance: opts.maintenance ?? makeMaintenance(false),
config: opts.config ?? makeConfig(),
// Phase 3 (SSH Console) deps — these tests don't exercise console tools,
// so a dummy registry + unreachable openShellChannel keep the interface
// satisfied without booting the headless terminal.
sessionRegistry: {
register: () => undefined,
get: () => null,
listAll: () => [],
listForConnection: () => [],
closeForTask: async () => undefined,
enforceCap: () => [],
sweep: async () => undefined,
startSweepTimer: () => undefined,
stopSweepTimer: () => undefined,
shutdown: async () => undefined,
} as unknown as SshSubsystem['sessionRegistry'],
openShellChannel: async () => {
throw new Error('openShellChannel not stubbed in this test');
},
};
return { sub, repos: { conn, audit, abuse, grants } };
}
function baseConnInput(overrides: Partial<CreateConnectionInput> = {}): CreateConnectionInput {
return {
ownerId: 'alice',
label: 'prod-srv',
host: 'srv.example.com',
port: 22,
username: 'deploy',
privateKeyEnc: Buffer.from('encrypted-pem'),
keyFingerprint: 'SHA256:fp',
remotePathPrefix: '/srv/agent',
...overrides,
};
}
async function ctxWithWorkspace(opts: {
workspace?: string;
userId?: string;
pieceName?: string;
allowed?: string[];
jobId?: string;
}): Promise<ToolContext> {
const workspace = opts.workspace ?? (await fsp.mkdtemp(path.join(os.tmpdir(), 'ssh-tool-')));
return {
workspacePath: workspace,
editAllowed: true,
userId: opts.userId ?? 'alice',
ownerId: opts.userId ?? 'alice',
pieceName: opts.pieceName ?? 'ops',
allowedSshConnections: opts.allowed,
jobId: opts.jobId ?? 'job1',
};
}
describe('engine/tools/ssh — subsystem gating', () => {
beforeEach(() => setSshSubsystem(null));
afterEach(() => setSshSubsystem(null));
it('rejects with "not initialised" when subsystem is null', async () => {
const ctx = await ctxWithWorkspace({ allowed: ['*'] });
const r = await executeTool('SshExec', { connection_id: 'x', command: 'whoami' }, ctx);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/not initialised/);
});
it('rejects with "in maintenance" when maintenance is active', async () => {
const db = bootstrapDb();
const { sub } = makeSubsystem({ db, maintenance: makeMaintenance(true) });
setSshSubsystem(sub);
const ctx = await ctxWithWorkspace({ allowed: ['*'] });
const r = await executeTool('SshExec', { connection_id: 'x', command: 'whoami' }, ctx);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/in maintenance/);
});
});
describe('engine/tools/ssh — preflight (piece + access + state)', () => {
let db: Database.Database;
let sub: SshSubsystem;
let repos: ReturnType<typeof makeSubsystem>['repos'];
let connId: string;
beforeEach(() => {
db = bootstrapDb();
({ sub, repos } = makeSubsystem({ db }));
setSshSubsystem(sub);
const created = repos.conn.create(
baseConnInput({
ownerId: 'alice',
commandDenyPatterns: '^rm -rf /\\b',
}),
);
// Verify the host key so SshExec passes step 6
db.prepare(
`UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
).run('hostb64', 'SHA256:host', new Date().toISOString(), created.id);
connId = created.id;
});
afterEach(() => setSshSubsystem(null));
it('rejects when allowed_ssh_connections is undefined', async () => {
const ctx = await ctxWithWorkspace({}); // allowed: undefined
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/does not declare allowed_ssh_connections/);
});
it('rejects when connection_id is not in allowed list', async () => {
const ctx = await ctxWithWorkspace({ allowed: ['00000000-other'] });
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/not in this piece's allowed_ssh_connections/);
const rows = repos.audit.listForConnection(connId, 10);
expect(rows[0].outcome).toBe('denied');
expect(rows[0].detail).toMatchObject({ reason: 'piece_not_allowed' });
});
it('honours wildcard "*" in allowed list', async () => {
const ctx = await ctxWithWorkspace({ allowed: ['*'] });
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
expect(r.isError).toBe(false);
});
it('rejects when connection does not exist', async () => {
const ctx = await ctxWithWorkspace({ allowed: ['ffffffff-aaaa-bbbb-cccc-dddddddddddd'] });
const r = await executeTool(
'SshExec',
{ connection_id: 'ffffffff-aaaa-bbbb-cccc-dddddddddddd', command: 'ls' },
ctx,
);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/does not exist/);
const rows = repos.audit.listPending().concat([]);
const denied = db.prepare(`SELECT * FROM ssh_audit_log WHERE outcome = 'denied' ORDER BY id DESC LIMIT 1`).get() as { detail: string };
expect(JSON.parse(denied.detail)).toMatchObject({ reason: 'unknown_connection' });
});
it('rejects non-owner without a grant', async () => {
const ctx = await ctxWithWorkspace({ userId: 'bob', allowed: [connId] });
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/access denied/);
const denied = db.prepare(`SELECT * FROM ssh_audit_log WHERE outcome = 'denied' ORDER BY id DESC LIMIT 1`).get() as { detail: string };
expect(JSON.parse(denied.detail)).toMatchObject({ reason: 'no_grant' });
});
it('rejects when admin disabled (access resolver catches before state check)', async () => {
repos.conn.disableByAdmin(connId, 'security review', 'admin1');
const ctx = await ctxWithWorkspace({ allowed: [connId] });
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
expect(r.isError).toBe(true);
// The access resolver short-circuits on `enabled=false` with reason='disabled' (step 5)
// before our explicit disabled_by_admin state check (step 6) runs. Both paths are correct;
// the resolver path is what fires.
expect(r.output).toMatch(/access denied \(disabled\)/);
});
it('rejects when abuse-locked', async () => {
// Saturate the abuse counter to trip the lock.
for (let i = 0; i < 5; i++) {
repos.abuse.checkAndRecordFailure({
connectionId: connId,
ownerId: 'alice',
userId: 'alice',
host: 'srv.example.com',
username: 'deploy',
});
}
const ctx = await ctxWithWorkspace({ allowed: [connId] });
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/temporarily locked/);
});
it('admin user is allowed without an explicit grant (adminBypassesGrants=true)', async () => {
const ctxAdmin = await ctxWithWorkspace({ userId: 'admin1', allowed: [connId] });
// Stub getUserAccess to mark admin1 as admin
setSshSubsystem({
...sub,
getUserAccess: (uid) => (uid === 'admin1' ? { isAdmin: true, orgIds: [] } : { isAdmin: false, orgIds: [] }),
});
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctxAdmin);
expect(r.isError).toBe(false);
});
});
describe('engine/tools/ssh SshExec', () => {
let db: Database.Database;
let sub: SshSubsystem;
let repos: ReturnType<typeof makeSubsystem>['repos'];
let connId: string;
beforeEach(() => {
db = bootstrapDb();
({ sub, repos } = makeSubsystem({ db }));
setSshSubsystem(sub);
const created = repos.conn.create(
baseConnInput({
commandDenyPatterns: '^rm -rf /\\b\n^dd\\s',
commandAllowPatterns: '^ls\\b\n^echo\\s\n^whoami\\b',
}),
);
db.prepare(
`UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
).run('hostb64', 'SHA256:host', new Date().toISOString(), created.id);
connId = created.id;
});
afterEach(() => setSshSubsystem(null));
it('rejects a command blocked by built-in deny list', async () => {
const ctx = await ctxWithWorkspace({ allowed: [connId] });
const r = await executeTool(
'SshExec',
{ connection_id: connId, command: 'rm -rf /' },
ctx,
);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/rejected by built-in deny pattern/);
});
it('rejects a command outside the allow list', async () => {
const ctx = await ctxWithWorkspace({ allowed: [connId] });
const r = await executeTool(
'SshExec',
{ connection_id: connId, command: 'curl http://x' },
ctx,
);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/not_in_allowlist/);
});
it('succeeds on an allowed command and records audit success', async () => {
const ctx = await ctxWithWorkspace({ allowed: [connId] });
const r = await executeTool(
'SshExec',
{ connection_id: connId, command: 'whoami' },
ctx,
);
expect(r.isError).toBe(false);
const parsed = JSON.parse(r.output) as { stdout: string; exit_code: number };
expect(parsed.exit_code).toBe(0);
const rows = repos.audit.listForConnection(connId, 10);
expect(rows[0].outcome).toBe('success');
expect(rows[0].detail).toMatchObject({ exit_code: 0 });
});
it('records audit "failed" and increments abuse counter on auth_failed', async () => {
const failed = makeSubsystem({
db,
stubs: {
execImpl: async () => {
throw new SshSessionError('auth_failed', 'bad key');
},
},
});
// Reuse the same db & repos so we observe state mutations
setSshSubsystem({ ...sub, sshExec: failed.sub.sshExec });
const ctx = await ctxWithWorkspace({ allowed: [connId] });
const r = await executeTool(
'SshExec',
{ connection_id: connId, command: 'whoami' },
ctx,
);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/SSH authentication failed/);
const rows = repos.audit.listForConnection(connId, 10);
expect(rows[0].outcome).toBe('failed');
expect(rows[0].detail).toMatchObject({ error: 'auth_failed' });
// Abuse counter should have ticked
const scope = repos.abuse.getByScopeKey(`conn:${connId}`);
expect(scope?.failure_count).toBe(1);
});
it('clears abuse counter on success', async () => {
// Pre-seed a failure
repos.abuse.checkAndRecordFailure({
connectionId: connId,
ownerId: 'alice',
userId: 'alice',
host: 'srv.example.com',
username: 'deploy',
});
expect(repos.abuse.getByScopeKey(`conn:${connId}`)?.failure_count).toBe(1);
const ctx = await ctxWithWorkspace({ allowed: [connId] });
const r = await executeTool(
'SshExec',
{ connection_id: connId, command: 'whoami' },
ctx,
);
expect(r.isError).toBe(false);
expect(repos.abuse.getByScopeKey(`conn:${connId}`)).toBeNull();
});
it('surfaces TOFU first_observe with token to the LLM', async () => {
setSshSubsystem({
...sub,
sshExec: async (_args, hooks) => {
const ret = await hooks.onFirstObserve({
connectionId: connId,
b64: 'observed-key-b64',
fingerprint: 'SHA256:newhost',
});
const err = new SshSessionError('host_key_first_observe', 'new host', {
fingerprint: 'SHA256:newhost',
token: ret?.token ?? 'no-token',
});
throw err;
},
});
const ctx = await ctxWithWorkspace({ allowed: [connId] });
const r = await executeTool(
'SshExec',
{ connection_id: connId, command: 'whoami' },
ctx,
);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/Host key first-observe/);
// Pending key was persisted on the connection
const conn = repos.conn.resolveConnection(connId);
expect(conn?.hostKeyPending).toBe(true);
expect(conn?.hostKeyPendingFingerprint).toBe('SHA256:newhost');
});
});
describe('engine/tools/ssh SshUpload', () => {
let db: Database.Database;
let sub: SshSubsystem;
let repos: ReturnType<typeof makeSubsystem>['repos'];
let connId: string;
let workspace: string;
beforeEach(async () => {
db = bootstrapDb();
({ sub, repos } = makeSubsystem({ db }));
setSshSubsystem(sub);
const created = repos.conn.create(baseConnInput({ remotePathPrefix: '/srv/agent' }));
db.prepare(
`UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
).run('hostb64', 'SHA256:host', new Date().toISOString(), created.id);
connId = created.id;
workspace = await fsp.mkdtemp(path.join(os.tmpdir(), 'ssh-upload-'));
await fsp.writeFile(path.join(workspace, 'hello.txt'), 'hi');
});
afterEach(() => setSshSubsystem(null));
it('rejects when host key is unverified', async () => {
// Clear the host_key_verified_at to simulate an un-verified connection
db.prepare(`UPDATE ssh_connections SET host_key_verified_at=NULL WHERE id=?`).run(connId);
const ctx = await ctxWithWorkspace({ allowed: [connId], workspace });
const r = await executeTool(
'SshUpload',
{ connection_id: connId, local_path: 'hello.txt', remote_path: '/srv/agent/h.txt' },
ctx,
);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/is not user-verified/);
});
it('rejects when local path escapes the workspace', async () => {
const ctx = await ctxWithWorkspace({ allowed: [connId], workspace });
const r = await executeTool(
'SshUpload',
{ connection_id: connId, local_path: '/etc/passwd', remote_path: '/srv/agent/h.txt' },
ctx,
);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/local path rejected/);
});
it('rejects when remote path is outside the prefix', async () => {
const ctx = await ctxWithWorkspace({ allowed: [connId], workspace });
const r = await executeTool(
'SshUpload',
{ connection_id: connId, local_path: 'hello.txt', remote_path: '/etc/passwd' },
ctx,
);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/remote path rejected/);
});
it('succeeds and records audit success', async () => {
const ctx = await ctxWithWorkspace({ allowed: [connId], workspace });
const r = await executeTool(
'SshUpload',
{ connection_id: connId, local_path: 'hello.txt', remote_path: '/srv/agent/h.txt' },
ctx,
);
expect(r.isError).toBe(false);
const body = JSON.parse(r.output) as { ok: boolean; bytes: number; remote: string };
expect(body.ok).toBe(true);
expect(body.remote).toBe('/srv/agent/h.txt');
const rows = repos.audit.listForConnection(connId, 10);
expect(rows[0].outcome).toBe('success');
});
});
describe('engine/tools/ssh SshDownload', () => {
let db: Database.Database;
let sub: SshSubsystem;
let repos: ReturnType<typeof makeSubsystem>['repos'];
let connId: string;
let workspace: string;
beforeEach(async () => {
db = bootstrapDb();
({ sub, repos } = makeSubsystem({
db,
stubs: {
downloadImpl: async (args) => {
// Simulate session writing the file so a subsequent validateLocalPath
// wouldn't be needed (but for now we just resolve happily).
return { bytes: 7, durationMs: 8, hostFingerprint: 'SHA256:fake' };
},
},
}));
setSshSubsystem(sub);
const created = repos.conn.create(baseConnInput({ remotePathPrefix: '/srv/agent' }));
db.prepare(
`UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
).run('hostb64', 'SHA256:host', new Date().toISOString(), created.id);
connId = created.id;
workspace = await fsp.mkdtemp(path.join(os.tmpdir(), 'ssh-download-'));
});
afterEach(() => setSshSubsystem(null));
it('succeeds on a fresh local target path', async () => {
const ctx = await ctxWithWorkspace({ allowed: [connId], workspace });
const r = await executeTool(
'SshDownload',
{ connection_id: connId, remote_path: '/srv/agent/data.bin', local_path: 'output/data.bin' },
ctx,
);
expect(r.isError).toBe(false);
const body = JSON.parse(r.output) as { ok: boolean; bytes: number };
expect(body.ok).toBe(true);
expect(body.bytes).toBe(7);
const rows = repos.audit.listForConnection(connId, 10);
expect(rows[0].outcome).toBe('success');
});
it('rejects when local path escapes workspace', async () => {
const ctx = await ctxWithWorkspace({ allowed: [connId], workspace });
const r = await executeTool(
'SshDownload',
{ connection_id: connId, remote_path: '/srv/agent/x', local_path: '/tmp/escape.bin' },
ctx,
);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/local path rejected/);
});
});
describe('engine/tools/ssh TOOL_DEFS', () => {
it('exposes the four SSH tools with required parameters', () => {
expect(Object.keys(TOOL_DEFS).sort()).toEqual([
'SshDownload', 'SshExec', 'SshListConnections', 'SshUpload',
]);
expect(TOOL_DEFS.SshExec.function.parameters.required).toEqual(['connection_id', 'command']);
expect(TOOL_DEFS.SshUpload.function.parameters.required).toEqual(['connection_id', 'local_path', 'remote_path']);
expect(TOOL_DEFS.SshDownload.function.parameters.required).toEqual(['connection_id', 'remote_path', 'local_path']);
expect(TOOL_DEFS.SshListConnections.function.parameters.required).toEqual([]);
});
});
describe('engine/tools/ssh SshListConnections', () => {
let db: Database.Database;
let sub: SshSubsystem;
let repos: { conn: SshConnectionRepo; audit: SshAuditRepo; abuse: SshAbuseRepo; grants: SshGrantsRepo };
beforeEach(() => {
db = bootstrapDb();
({ sub, repos } = makeSubsystem({ db }));
setSshSubsystem(sub);
});
afterEach(() => {
setSshSubsystem(null);
db.close();
});
it('rejects when piece does not declare allowed_ssh_connections', async () => {
const ctx = await ctxWithWorkspace({ allowed: undefined });
const r = await executeTool('SshListConnections', {}, ctx);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/does not declare allowed_ssh_connections/);
});
it('returns empty array when no connections exist', async () => {
const ctx = await ctxWithWorkspace({ allowed: ['*'] });
const r = await executeTool('SshListConnections', {}, ctx);
expect(r.isError).toBe(false);
expect(JSON.parse(r.output)).toEqual({ connections: [] });
});
it('wildcard returns owner+granted connections only (others without grant filtered)', async () => {
const a = repos.conn.create(baseConnInput({ ownerId: 'alice', label: 'alice-srv' }));
// bob-owned: alice has no grant → access denied → filtered out.
repos.conn.create(baseConnInput({ ownerId: 'bob', label: 'bob-srv' }));
const ctx = await ctxWithWorkspace({ allowed: ['*'], userId: 'alice' });
const r = await executeTool('SshListConnections', {}, ctx);
expect(r.isError).toBe(false);
const parsed = JSON.parse(r.output);
expect(parsed.connections.map((c: any) => c.label)).toEqual(['alice-srv']);
expect(parsed.connections[0]).toMatchObject({
id: a.id,
label: 'alice-srv',
host: 'srv.example.com',
port: 22,
username: 'deploy',
host_key_verified: false,
host_key_pending: false,
});
});
it('explicit UUID list filters out non-matching connections', async () => {
const a = repos.conn.create(baseConnInput({ ownerId: 'alice', label: 'a' }));
const c = repos.conn.create(baseConnInput({ ownerId: 'alice', label: 'c' }));
const ctx = await ctxWithWorkspace({ allowed: [a.id], userId: 'alice' });
const r = await executeTool('SshListConnections', {}, ctx);
const parsed = JSON.parse(r.output);
expect(parsed.connections.map((x: any) => x.id)).toEqual([a.id]);
expect(parsed.connections.map((x: any) => x.id)).not.toContain(c.id);
});
it('writes an audit row with action ssh.list_connections', async () => {
repos.conn.create(baseConnInput({ ownerId: 'alice', label: 'x' }));
const ctx = await ctxWithWorkspace({ allowed: ['*'], userId: 'alice' });
await executeTool('SshListConnections', {}, ctx);
const row = db
.prepare(
"SELECT action, outcome FROM ssh_audit_log WHERE action = 'ssh.list_connections' ORDER BY id DESC LIMIT 1",
)
.get() as { action: string; outcome: string } | undefined;
expect(row).toBeDefined();
expect(row!.outcome).toBe('success');
});
});