/** * 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 { 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 extends Promise ? R : never>; uploadImpl?: (args: UploadArgs, hooks: SessionHooks) => Promise extends Promise ? R : never>; downloadImpl?: (args: DownloadArgs, hooks: SessionHooks) => Promise extends Promise ? R : never>; } function makeSubsystem(opts: { db: Database.Database; config?: SshRuntimeConfig; maintenance?: MaintenanceController; stubs?: Stubs; userAccess?: Record; }): { 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 { 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 { 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['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['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['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['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'); }); });