mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'
|
|
|
|
vi.mock('os', () => ({
|
|
homedir: () => '/home/testuser'
|
|
}))
|
|
|
|
const mockExistsSync = vi.fn().mockReturnValue(false)
|
|
const mockReadFileSync = vi.fn()
|
|
|
|
vi.mock('fs', () => ({
|
|
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
|
readFileSync: (...args: unknown[]) => mockReadFileSync(...args)
|
|
}))
|
|
|
|
import {
|
|
isTransientError,
|
|
isAuthError,
|
|
sleep,
|
|
shellEscape,
|
|
findDefaultKeyFile,
|
|
buildConnectConfig,
|
|
resolveEffectiveProxy,
|
|
CONNECT_TIMEOUT_MS,
|
|
INITIAL_RETRY_ATTEMPTS,
|
|
INITIAL_RETRY_DELAY_MS,
|
|
RECONNECT_BACKOFF_MS
|
|
} from './ssh-connection-utils'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
import type { SshResolvedConfig } from './ssh-config-parser'
|
|
|
|
// ── Constants ────────────────────────────────────────────────────────
|
|
|
|
describe('SSH connection constants', () => {
|
|
it('CONNECT_TIMEOUT_MS is 30 seconds (matches VS Code)', () => {
|
|
expect(CONNECT_TIMEOUT_MS).toBe(30_000)
|
|
})
|
|
|
|
it('INITIAL_RETRY_ATTEMPTS is 5', () => {
|
|
expect(INITIAL_RETRY_ATTEMPTS).toBe(5)
|
|
})
|
|
|
|
it('INITIAL_RETRY_DELAY_MS is 2 seconds', () => {
|
|
expect(INITIAL_RETRY_DELAY_MS).toBe(2000)
|
|
})
|
|
|
|
it('RECONNECT_BACKOFF_MS has 9 entries', () => {
|
|
expect(RECONNECT_BACKOFF_MS).toHaveLength(9)
|
|
})
|
|
})
|
|
|
|
// ── isTransientError ─────────────────────────────────────────────────
|
|
|
|
describe('isTransientError', () => {
|
|
it('returns true for ETIMEDOUT code', () => {
|
|
const err = new Error('timed out') as NodeJS.ErrnoException
|
|
err.code = 'ETIMEDOUT'
|
|
expect(isTransientError(err)).toBe(true)
|
|
})
|
|
|
|
it('returns true for ECONNREFUSED code', () => {
|
|
const err = new Error('refused') as NodeJS.ErrnoException
|
|
err.code = 'ECONNREFUSED'
|
|
expect(isTransientError(err)).toBe(true)
|
|
})
|
|
|
|
it('returns true for ECONNRESET code', () => {
|
|
const err = new Error('reset') as NodeJS.ErrnoException
|
|
err.code = 'ECONNRESET'
|
|
expect(isTransientError(err)).toBe(true)
|
|
})
|
|
|
|
it('returns true for EHOSTUNREACH code', () => {
|
|
const err = new Error('host unreachable') as NodeJS.ErrnoException
|
|
err.code = 'EHOSTUNREACH'
|
|
expect(isTransientError(err)).toBe(true)
|
|
})
|
|
|
|
it('returns true for ENETUNREACH code', () => {
|
|
const err = new Error('net unreachable') as NodeJS.ErrnoException
|
|
err.code = 'ENETUNREACH'
|
|
expect(isTransientError(err)).toBe(true)
|
|
})
|
|
|
|
it('returns true for EAI_AGAIN code', () => {
|
|
const err = new Error('dns') as NodeJS.ErrnoException
|
|
err.code = 'EAI_AGAIN'
|
|
expect(isTransientError(err)).toBe(true)
|
|
})
|
|
|
|
it('returns true for ETIMEDOUT in message (no code)', () => {
|
|
expect(isTransientError(new Error('connect ETIMEDOUT 1.2.3.4:22'))).toBe(true)
|
|
})
|
|
|
|
it('returns true for ECONNREFUSED in message', () => {
|
|
expect(isTransientError(new Error('connect ECONNREFUSED 1.2.3.4:22'))).toBe(true)
|
|
})
|
|
|
|
it('returns true for ECONNRESET in message', () => {
|
|
expect(isTransientError(new Error('read ECONNRESET'))).toBe(true)
|
|
})
|
|
|
|
it('returns false for auth errors', () => {
|
|
expect(isTransientError(new Error('All configured authentication methods failed'))).toBe(false)
|
|
})
|
|
|
|
it('returns false for generic errors', () => {
|
|
expect(isTransientError(new Error('something went wrong'))).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ── isAuthError ──────────────────────────────────────────────────────
|
|
|
|
describe('isAuthError', () => {
|
|
it('returns true for "All configured authentication methods failed"', () => {
|
|
expect(isAuthError(new Error('All configured authentication methods failed'))).toBe(true)
|
|
})
|
|
|
|
it('returns true for "Authentication failed"', () => {
|
|
expect(isAuthError(new Error('Authentication failed'))).toBe(true)
|
|
})
|
|
|
|
it('returns true for client-authentication level', () => {
|
|
const err = new Error('auth') as Error & { level: string }
|
|
err.level = 'client-authentication'
|
|
expect(isAuthError(err)).toBe(true)
|
|
})
|
|
|
|
it('returns false for transient errors', () => {
|
|
expect(isAuthError(new Error('connect ETIMEDOUT'))).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ── sleep ────────────────────────────────────────────────────────────
|
|
|
|
describe('sleep', () => {
|
|
it('resolves after the given delay', async () => {
|
|
const start = Date.now()
|
|
await sleep(50)
|
|
expect(Date.now() - start).toBeGreaterThanOrEqual(40)
|
|
})
|
|
})
|
|
|
|
// ── shellEscape ──────────────────────────────────────────────────────
|
|
|
|
describe('shellEscape', () => {
|
|
it('wraps string in single quotes', () => {
|
|
expect(shellEscape('hello')).toBe("'hello'")
|
|
})
|
|
|
|
it('escapes embedded single quotes', () => {
|
|
expect(shellEscape("it's")).toBe("'it'\\''s'")
|
|
})
|
|
|
|
it('handles empty string', () => {
|
|
expect(shellEscape('')).toBe("''")
|
|
})
|
|
|
|
it('handles special characters', () => {
|
|
expect(shellEscape('foo bar; rm -rf /')).toBe("'foo bar; rm -rf /'")
|
|
})
|
|
})
|
|
|
|
// ── findDefaultKeyFile ───────────────────────────────────────────────
|
|
|
|
describe('findDefaultKeyFile', () => {
|
|
beforeEach(() => {
|
|
mockExistsSync.mockReturnValue(false)
|
|
mockReadFileSync.mockReset()
|
|
})
|
|
|
|
it('returns undefined when no default keys exist', () => {
|
|
expect(findDefaultKeyFile()).toBeUndefined()
|
|
})
|
|
|
|
it('returns the first existing key file', () => {
|
|
mockExistsSync.mockImplementation((path: unknown) => {
|
|
return path === '/home/testuser/.ssh/id_ed25519'
|
|
})
|
|
mockReadFileSync.mockReturnValue(Buffer.from('key-contents'))
|
|
|
|
const result = findDefaultKeyFile()
|
|
expect(result).toBeDefined()
|
|
expect(result!.path).toBe('~/.ssh/id_ed25519')
|
|
expect(result!.contents).toEqual(Buffer.from('key-contents'))
|
|
})
|
|
|
|
it('probes keys in VS Code order: ed25519, rsa, ecdsa, dsa, xmss', () => {
|
|
const checkedPaths: string[] = []
|
|
mockExistsSync.mockImplementation((path: unknown) => {
|
|
checkedPaths.push(String(path))
|
|
return false
|
|
})
|
|
|
|
findDefaultKeyFile()
|
|
|
|
expect(checkedPaths).toEqual([
|
|
'/home/testuser/.ssh/id_ed25519',
|
|
'/home/testuser/.ssh/id_rsa',
|
|
'/home/testuser/.ssh/id_ecdsa',
|
|
'/home/testuser/.ssh/id_dsa',
|
|
'/home/testuser/.ssh/id_xmss'
|
|
])
|
|
})
|
|
|
|
it('skips unreadable key files and tries next', () => {
|
|
mockExistsSync.mockImplementation((path: unknown) => {
|
|
return path === '/home/testuser/.ssh/id_ed25519' || path === '/home/testuser/.ssh/id_rsa'
|
|
})
|
|
mockReadFileSync.mockImplementation((path: unknown) => {
|
|
if (String(path) === '/home/testuser/.ssh/id_ed25519') {
|
|
throw new Error('permission denied')
|
|
}
|
|
return Buffer.from('rsa-key')
|
|
})
|
|
|
|
const result = findDefaultKeyFile()
|
|
expect(result).toBeDefined()
|
|
expect(result!.path).toBe('~/.ssh/id_rsa')
|
|
})
|
|
})
|
|
|
|
// ── buildConnectConfig ──────────────────────────────────────────────
|
|
|
|
function makeTarget(overrides?: Partial<SshTarget>): SshTarget {
|
|
return {
|
|
id: 'test-1',
|
|
label: 'myhost',
|
|
host: 'example.com',
|
|
port: 22,
|
|
username: 'deploy',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function makeResolved(overrides?: Partial<SshResolvedConfig>): SshResolvedConfig {
|
|
return {
|
|
hostname: '10.0.0.1',
|
|
port: 22,
|
|
identityFile: [],
|
|
forwardAgent: false,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe('buildConnectConfig', () => {
|
|
const originalEnv = process.env.SSH_AUTH_SOCK
|
|
|
|
beforeEach(() => {
|
|
mockExistsSync.mockReturnValue(false)
|
|
mockReadFileSync.mockReset()
|
|
process.env.SSH_AUTH_SOCK = '/tmp/agent.sock'
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (originalEnv !== undefined) {
|
|
process.env.SSH_AUTH_SOCK = originalEnv
|
|
} else {
|
|
delete process.env.SSH_AUTH_SOCK
|
|
}
|
|
})
|
|
|
|
it('uses target host/port/username', () => {
|
|
const config = buildConnectConfig(makeTarget(), null)
|
|
expect(config.host).toBe('example.com')
|
|
expect(config.port).toBe(22)
|
|
expect(config.username).toBe('deploy')
|
|
})
|
|
|
|
it('falls back to resolved config when target fields are empty', () => {
|
|
const config = buildConnectConfig(
|
|
makeTarget({ host: '', port: 0, username: '' }),
|
|
makeResolved({ hostname: '10.0.0.1', port: 2222, user: 'admin' })
|
|
)
|
|
expect(config.host).toBe('10.0.0.1')
|
|
expect(config.port).toBe(2222)
|
|
expect(config.username).toBe('admin')
|
|
})
|
|
|
|
it('sets readyTimeout to CONNECT_TIMEOUT_MS', () => {
|
|
const config = buildConnectConfig(makeTarget(), null)
|
|
expect(config.readyTimeout).toBe(30_000)
|
|
})
|
|
|
|
it('sets keepaliveInterval to 15s', () => {
|
|
const config = buildConnectConfig(makeTarget(), null)
|
|
expect(config.keepaliveInterval).toBe(15_000)
|
|
})
|
|
|
|
it('uses agent auth when no explicit key and SSH_AUTH_SOCK is set', () => {
|
|
const config = buildConnectConfig(makeTarget(), null)
|
|
expect(config.agent).toBe('/tmp/agent.sock')
|
|
})
|
|
|
|
it('uses keyFile auth when target.identityFile is set', () => {
|
|
mockReadFileSync.mockReturnValue(Buffer.from('key'))
|
|
const config = buildConnectConfig(makeTarget({ identityFile: '/home/user/.ssh/custom' }), null)
|
|
expect(config.privateKey).toEqual(Buffer.from('key'))
|
|
expect(config.agent).toBe('/tmp/agent.sock')
|
|
})
|
|
|
|
it('uses keyFile auth when resolved identityFile is non-default', () => {
|
|
mockReadFileSync.mockReturnValue(Buffer.from('custom-key'))
|
|
const config = buildConnectConfig(
|
|
makeTarget(),
|
|
makeResolved({ identityFile: ['/home/user/.ssh/work_key'] })
|
|
)
|
|
expect(config.privateKey).toEqual(Buffer.from('custom-key'))
|
|
expect(config.agent).toBe('/tmp/agent.sock')
|
|
})
|
|
|
|
it('uses agent auth when resolved identityFile is a default path (expanded)', () => {
|
|
const config = buildConnectConfig(
|
|
makeTarget(),
|
|
makeResolved({ identityFile: ['/home/testuser/.ssh/id_ed25519'] })
|
|
)
|
|
expect(config.agent).toBe('/tmp/agent.sock')
|
|
})
|
|
|
|
it('provides fallback key in agent auth mode', () => {
|
|
mockExistsSync.mockImplementation(
|
|
(p: unknown) => String(p) === '/home/testuser/.ssh/id_ed25519'
|
|
)
|
|
mockReadFileSync.mockReturnValue(Buffer.from('fallback'))
|
|
const config = buildConnectConfig(makeTarget(), null)
|
|
expect(config.agent).toBe('/tmp/agent.sock')
|
|
expect(config.privateKey).toEqual(Buffer.from('fallback'))
|
|
})
|
|
})
|
|
|
|
// ── resolveEffectiveProxy ───────────────────────────────────────────
|
|
|
|
describe('resolveEffectiveProxy', () => {
|
|
it('returns target.proxyCommand first', () => {
|
|
const target = { ...makeTarget(), proxyCommand: 'cloudflared access ssh --hostname %h' }
|
|
const resolved = makeResolved({ proxyCommand: 'other' })
|
|
expect(resolveEffectiveProxy(target, resolved)).toEqual({
|
|
kind: 'proxy-command',
|
|
command: 'cloudflared access ssh --hostname %h'
|
|
})
|
|
})
|
|
|
|
it('falls back to resolved proxyCommand', () => {
|
|
expect(
|
|
resolveEffectiveProxy(makeTarget(), makeResolved({ proxyCommand: 'ssh -W %h:%p gw' }))
|
|
).toEqual({
|
|
kind: 'proxy-command',
|
|
command: 'ssh -W %h:%p gw'
|
|
})
|
|
})
|
|
|
|
it('returns structured jump-host config for target.jumpHost', () => {
|
|
const target = { ...makeTarget(), jumpHost: 'bastion.example.com' }
|
|
expect(resolveEffectiveProxy(target, null)).toEqual({
|
|
kind: 'jump-host',
|
|
jumpHost: 'bastion.example.com'
|
|
})
|
|
})
|
|
|
|
it('returns structured jump-host config for resolved proxyJump', () => {
|
|
expect(resolveEffectiveProxy(makeTarget(), makeResolved({ proxyJump: 'jump.host' }))).toEqual({
|
|
kind: 'jump-host',
|
|
jumpHost: 'jump.host'
|
|
})
|
|
})
|
|
|
|
it('returns undefined when no proxy is configured', () => {
|
|
expect(resolveEffectiveProxy(makeTarget(), null)).toBeUndefined()
|
|
})
|
|
})
|