Files
orca/src/main/ssh/ssh-connection-utils.test.ts
T

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()
})
})