mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
* feat(ssh): add SSH config host picker for add-host form Users can now click 'Fill from ~/.ssh/config…' to browse available SSH config hosts in a picker, select one, and have the form automatically prefill with resolved connection details (hostname, port, username, auth). Previously, an 'import' button provided bulk sync on this form—confusing and unhelpful when everything was already synced. That action is now available as a secondary 'Add all' option in the picker. * fix(ssh): import filter preservation and label fallback - Reuse search loader on import completion to preserve active filter inside generation guard - Fall back to hostname when manual host has no label, not empty string - Make alias duplicate detection case-insensitive to match config picker behavior - Validate host availability when restoring project group selection - Add aria-selected attribute to picker options for accessibility * fix(ssh): harden config picker import, alias folding, and host targeting Review findings on the ~/.ssh/config picker + bulk add: - Guard config-host resolution with a generation counter so a late resolve cannot overwrite a later pick or a form the user backed out of; freeze the other rows while a pick resolves. - Stop "Add all N" from re-adopting deleted hosts — it now imports without reAdopt, matching the new-host count it advertises. Settings → Import keeps the explicit re-adopt path. - Fold SSH aliases through a shared normalizeSshConfigAlias for import ownership, delete tombstones, reclaim, picker search, and the save-time duplicate check, which now occupies configHost *and* label like the picker. - Persist GSSAPIAuthentication only when a parsed Host entry asks for it, not when `ssh -G` merely echoes the /etc/ssh system default. - Fail closed with unavailable/setup-not-found when an explicit projectHostSetupId names a non-actionable host instead of silently creating the workspace on a sibling host. - Cache the parsed config for the picker session (refresh on open/retry) so filter keystrokes no longer reparse and Include-expand the file, keep the filter usable during loads, add a Retry on load errors, explain an empty Identity file after a config fill, and drop the always-false aria-selected. * refactor(ssh): centralize host result limit and extract folder group val Move SSH_CONFIG_HOST_RESULT_LIMIT to shared types so the renderer's limit message cannot drift from the host's query limit. Extract findActionableFolderProjectGroup to avoid repeating the folder-host-availability check across the composer hook. * fix(ssh): pass -F to ssh -G when HOME differs from passwd home In E2E tests and sandboxes, isolated HOME can differ from the system passwd home. OpenSSH resolves the default config via getpwuid (passwd), while Node's loadUserSshConfig uses os.homedir() (HOME-aware). Pass -F to explicitly specify the config path when they diverge, so ssh -G and the picker resolve the same file. * fix(ssh): verify config host exists before resolving with ssh -G When a user edits ~/.ssh/config and removes a host, the import picker should not fall back to ssh -G's echoed response (which treats any alias as valid). Check the reloaded config file before resolving. - Force reload config on each resolve to catch user edits post-open - Reject aliases not in the current config before calling ssh -G - Add test for deleted alias edge case - Fix workspace-target fallback to honor explicit host selection * fix(ssh): let tombstoned aliases be re-picked in the config picker Allow users to reclaim a deleted SSH host by re-picking it from ~/.ssh/config. Tombstoned aliases now appear in the picker with a "Removed from Orca" badge and remain pickable, but don't count toward "Add all" operations — ensuring passive import never resurrects a deleted alias while still giving the user a recovery path.
686 lines
20 KiB
TypeScript
686 lines
20 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { join } from 'node:path'
|
|
import { parseSshConfig, sshConfigHostsToTargets, parseSshGOutput } from './ssh-config-parser'
|
|
import { searchSshConfigHosts } from './ssh-config-host-picker'
|
|
|
|
const sshConfigHostsToSummaries = (
|
|
hosts: Parameters<typeof searchSshConfigHosts>[0],
|
|
targets: Parameters<typeof searchSshConfigHosts>[1]
|
|
) => searchSshConfigHosts(hosts, targets).hosts
|
|
|
|
vi.mock('os', () => ({
|
|
homedir: () => '/home/testuser'
|
|
}))
|
|
|
|
const LARGE_HOST_ALIAS_COUNT = 150_000
|
|
const TEST_HOME = '/home/testuser'
|
|
|
|
function testHomePath(...parts: string[]): string {
|
|
return join(TEST_HOME, ...parts)
|
|
}
|
|
|
|
function buildHostAliases(count: number): string {
|
|
const aliases: string[] = []
|
|
for (let index = 0; index < count; index += 1) {
|
|
aliases.push(`generated-${index}`)
|
|
}
|
|
return aliases.join(' ')
|
|
}
|
|
|
|
describe('parseSshConfig', () => {
|
|
it('parses a basic host block', () => {
|
|
const config = `
|
|
Host myserver
|
|
HostName 192.168.1.100
|
|
User deploy
|
|
Port 2222
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts).toHaveLength(1)
|
|
expect(hosts[0]).toEqual({
|
|
host: 'myserver',
|
|
hostname: '192.168.1.100',
|
|
user: 'deploy',
|
|
port: 2222
|
|
})
|
|
})
|
|
|
|
it('parses multiple host blocks', () => {
|
|
const config = `
|
|
Host staging
|
|
HostName staging.example.com
|
|
User admin
|
|
|
|
Host production
|
|
HostName prod.example.com
|
|
User deploy
|
|
Port 2222
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts).toHaveLength(2)
|
|
expect(hosts[0].host).toBe('staging')
|
|
expect(hosts[1].host).toBe('production')
|
|
expect(hosts[1].port).toBe(2222)
|
|
})
|
|
|
|
it('skips wildcard-only Host entries', () => {
|
|
const config = `
|
|
Host *
|
|
ServerAliveInterval 60
|
|
|
|
Host myserver
|
|
HostName 10.0.0.1
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts).toHaveLength(1)
|
|
expect(hosts[0].host).toBe('myserver')
|
|
})
|
|
|
|
it('skips Host entries with only pattern characters', () => {
|
|
const config = `
|
|
Host *.example.com
|
|
User admin
|
|
|
|
Host dev
|
|
HostName dev.example.com
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts).toHaveLength(1)
|
|
expect(hosts[0].host).toBe('dev')
|
|
})
|
|
|
|
it('parses IdentityFile with ~ expansion', () => {
|
|
const config = `
|
|
Host myserver
|
|
HostName example.com
|
|
IdentityFile ~/.ssh/id_ed25519
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts[0].identityFile).toBe(testHomePath('.ssh', 'id_ed25519'))
|
|
})
|
|
|
|
it('parses Windows-style IdentityFile with ~ expansion', () => {
|
|
const config = `
|
|
Host myserver
|
|
HostName example.com
|
|
IdentityFile ~\\.ssh\\id_ed25519
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts[0].identityFile).toBe(testHomePath('.ssh', 'id_ed25519'))
|
|
})
|
|
|
|
it('parses quoted scalar values like OpenSSH', () => {
|
|
const config = `
|
|
Host quoted
|
|
HostName "localhost" # local test
|
|
User "deploy" # deploy user
|
|
Port "2202" # ssh port
|
|
IdentityFile "~/.ssh/id with space" # private key
|
|
IdentityAgent "~/.1password/agent sock" # agent socket
|
|
IdentitiesOnly "yes" # limit keys
|
|
ProxyJump "bastion" # jump host
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts[0]).toEqual({
|
|
host: 'quoted',
|
|
hostname: 'localhost',
|
|
user: 'deploy',
|
|
port: 2202,
|
|
identityFile: testHomePath('.ssh', 'id with space'),
|
|
identityAgent: testHomePath('.1password', 'agent sock'),
|
|
identitiesOnly: true,
|
|
proxyJump: 'bastion'
|
|
})
|
|
})
|
|
|
|
it('parses equals-form scalar directives', () => {
|
|
const config = `
|
|
Host eq
|
|
HostName=eq.example.com
|
|
User=deploy
|
|
Port=2202
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts[0]).toEqual({
|
|
host: 'eq',
|
|
hostname: 'eq.example.com',
|
|
user: 'deploy',
|
|
port: 2202
|
|
})
|
|
})
|
|
|
|
it('parses IdentityAgent with ~ expansion', () => {
|
|
const config = `
|
|
Host myserver
|
|
HostName example.com
|
|
IdentityAgent ~/.1password/agent.sock
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts[0].identityAgent).toBe(testHomePath('.1password', 'agent.sock'))
|
|
})
|
|
|
|
it('parses IdentitiesOnly', () => {
|
|
const config = `
|
|
Host myserver
|
|
HostName example.com
|
|
IdentitiesOnly yes
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts[0].identitiesOnly).toBe(true)
|
|
})
|
|
|
|
it('parses GSSAPIAuthentication', () => {
|
|
const config = `
|
|
Host krb-host
|
|
HostName krb.example.com
|
|
GSSAPIAuthentication yes
|
|
|
|
Host plain-host
|
|
HostName plain.example.com
|
|
GSSAPIAuthentication no
|
|
|
|
Host silent-host
|
|
HostName silent.example.com
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts[0].gssapiAuthentication).toBe(true)
|
|
expect(hosts[1].gssapiAuthentication).toBe(false)
|
|
expect(hosts[2].gssapiAuthentication).toBeUndefined()
|
|
})
|
|
|
|
it('keeps the first GSSAPIAuthentication value like OpenSSH', () => {
|
|
const hosts = parseSshConfig(`
|
|
Host enabled
|
|
GSSAPIAuthentication yes
|
|
GSSAPIAuthentication no
|
|
|
|
Host disabled
|
|
GSSAPIAuthentication no
|
|
GSSAPIAuthentication yes
|
|
`)
|
|
|
|
expect(hosts[0].gssapiAuthentication).toBe(true)
|
|
expect(hosts[1].gssapiAuthentication).toBe(false)
|
|
})
|
|
|
|
it('keeps the first value for repeated single-valued options like OpenSSH', () => {
|
|
const hosts = parseSshConfig(`
|
|
Host repeated
|
|
HostName first.example.com
|
|
HostName second.example.com
|
|
User first-user
|
|
User second-user
|
|
Port 2201
|
|
Port 2202
|
|
IdentityAgent ~/.ssh/first-agent.sock
|
|
IdentityAgent ~/.ssh/second-agent.sock
|
|
IdentitiesOnly yes
|
|
IdentitiesOnly no
|
|
ProxyCommand ssh -W %h:%p first-bastion
|
|
ProxyCommand ssh -W %h:%p second-bastion
|
|
ProxyUseFdpass yes
|
|
ProxyUseFdpass no
|
|
ProxyJump first-jump
|
|
ProxyJump second-jump
|
|
|
|
Host repeated-disabled
|
|
IdentitiesOnly no
|
|
IdentitiesOnly yes
|
|
ProxyUseFdpass no
|
|
ProxyUseFdpass yes
|
|
`)
|
|
|
|
expect(hosts[0]).toEqual({
|
|
host: 'repeated',
|
|
hostname: 'first.example.com',
|
|
user: 'first-user',
|
|
port: 2201,
|
|
identityAgent: testHomePath('.ssh', 'first-agent.sock'),
|
|
identitiesOnly: true,
|
|
proxyCommand: 'ssh -W %h:%p first-bastion',
|
|
proxyUseFdpass: true,
|
|
proxyJump: 'first-jump'
|
|
})
|
|
expect(hosts[1]).toEqual({
|
|
host: 'repeated-disabled',
|
|
identitiesOnly: false,
|
|
proxyUseFdpass: false
|
|
})
|
|
})
|
|
|
|
it('parses ProxyCommand, ProxyUseFdpass, and ProxyJump', () => {
|
|
const config = `
|
|
Host internal
|
|
HostName 10.0.0.5
|
|
ProxyCommand ssh -W %h:%p bastion
|
|
ProxyUseFdpass yes
|
|
ProxyJump bastion.example.com
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts[0].proxyCommand).toBe('ssh -W %h:%p bastion')
|
|
expect(hosts[0].proxyUseFdpass).toBe(true)
|
|
expect(hosts[0].proxyJump).toBe('bastion.example.com')
|
|
})
|
|
|
|
it('preserves ProxyCommand as the rest of the line', () => {
|
|
const config = `
|
|
Host internal
|
|
ProxyCommand sh -c "nc %h %p" # shell comment
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts[0].proxyCommand).toBe('sh -c "nc %h %p" # shell comment')
|
|
})
|
|
|
|
it('ignores comments and blank lines', () => {
|
|
const config = `
|
|
# This is a comment
|
|
Host myserver
|
|
# Another comment
|
|
HostName example.com
|
|
|
|
User admin
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts).toHaveLength(1)
|
|
expect(hosts[0].user).toBe('admin')
|
|
})
|
|
|
|
it('handles case-insensitive keywords', () => {
|
|
const config = `
|
|
Host myserver
|
|
hostname EXAMPLE.COM
|
|
user Admin
|
|
port 3022
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts[0].hostname).toBe('EXAMPLE.COM')
|
|
expect(hosts[0].user).toBe('Admin')
|
|
expect(hosts[0].port).toBe(3022)
|
|
})
|
|
|
|
it('stops current block on Match directive', () => {
|
|
const config = `
|
|
Host myserver
|
|
HostName example.com
|
|
|
|
Match host *.internal
|
|
User internal-admin
|
|
|
|
Host other
|
|
HostName other.com
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts).toHaveLength(2)
|
|
expect(hosts[0].host).toBe('myserver')
|
|
expect(hosts[1].host).toBe('other')
|
|
})
|
|
|
|
it('returns empty array for empty input', () => {
|
|
expect(parseSshConfig('')).toEqual([])
|
|
})
|
|
|
|
it('creates one parsed host per concrete alias on a multi-pattern Host line', () => {
|
|
const config = `
|
|
Host staging stage *.example.com
|
|
HostName staging.example.com
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts).toEqual([
|
|
{ host: 'staging', hostname: 'staging.example.com' },
|
|
{ host: 'stage', hostname: 'staging.example.com' }
|
|
])
|
|
})
|
|
|
|
it('parses large concrete alias lists on one Host line', () => {
|
|
const config = [
|
|
`Host ${buildHostAliases(LARGE_HOST_ALIAS_COUNT)}`,
|
|
' HostName generated.example.com',
|
|
'Host after',
|
|
' HostName after.example.com'
|
|
].join('\n')
|
|
|
|
const hosts = parseSshConfig(config)
|
|
|
|
expect(hosts).toHaveLength(LARGE_HOST_ALIAS_COUNT + 1)
|
|
expect(hosts[0]).toEqual({
|
|
host: 'generated-0',
|
|
hostname: 'generated.example.com'
|
|
})
|
|
expect(hosts[LARGE_HOST_ALIAS_COUNT - 1]).toEqual({
|
|
host: `generated-${LARGE_HOST_ALIAS_COUNT - 1}`,
|
|
hostname: 'generated.example.com'
|
|
})
|
|
expect(hosts.at(-1)).toEqual({
|
|
host: 'after',
|
|
hostname: 'after.example.com'
|
|
})
|
|
})
|
|
|
|
it('applies identity agent settings to every concrete alias on a multi-pattern Host line', () => {
|
|
const config = `
|
|
Host staging stage
|
|
IdentityAgent ~/.1password/agent.sock
|
|
IdentitiesOnly yes
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts).toEqual([
|
|
{
|
|
host: 'staging',
|
|
identityAgent: testHomePath('.1password', 'agent.sock'),
|
|
identitiesOnly: true
|
|
},
|
|
{
|
|
host: 'stage',
|
|
identityAgent: testHomePath('.1password', 'agent.sock'),
|
|
identitiesOnly: true
|
|
}
|
|
])
|
|
})
|
|
|
|
it('defaults port to 22 for invalid port values', () => {
|
|
const config = `
|
|
Host myserver
|
|
Port notanumber
|
|
`
|
|
const hosts = parseSshConfig(config)
|
|
expect(hosts[0].port).toBe(22)
|
|
})
|
|
})
|
|
|
|
describe('sshConfigHostsToTargets', () => {
|
|
it('converts hosts to SshTarget objects', () => {
|
|
const hosts = [{ host: 'myserver', hostname: '10.0.0.1', port: 22, user: 'deploy' }]
|
|
const targets = sshConfigHostsToTargets(hosts, new Set())
|
|
expect(targets).toHaveLength(1)
|
|
expect(targets[0]).toMatchObject({
|
|
label: 'myserver',
|
|
host: '10.0.0.1',
|
|
port: 22,
|
|
username: 'deploy'
|
|
})
|
|
expect(targets[0].id).toMatch(/^ssh-/)
|
|
})
|
|
|
|
it('uses host alias as hostname when HostName is missing', () => {
|
|
const hosts = [{ host: 'myserver' }]
|
|
const targets = sshConfigHostsToTargets(hosts, new Set())
|
|
expect(targets[0].host).toBe('myserver')
|
|
})
|
|
|
|
it('skips hosts that are already imported', () => {
|
|
const hosts = [
|
|
{ host: 'existing', hostname: '10.0.0.1' },
|
|
{ host: 'new-host', hostname: '10.0.0.2' }
|
|
]
|
|
const targets = sshConfigHostsToTargets(hosts, new Set(['existing']))
|
|
expect(targets).toHaveLength(1)
|
|
expect(targets[0].label).toBe('new-host')
|
|
})
|
|
|
|
it('defaults username to empty string when not specified', () => {
|
|
const hosts = [{ host: 'nouser', hostname: '10.0.0.1' }]
|
|
const targets = sshConfigHostsToTargets(hosts, new Set())
|
|
expect(targets[0].username).toBe('')
|
|
})
|
|
|
|
it('carries through identityFile, identityAgent, identitiesOnly, proxyCommand, and jumpHost', () => {
|
|
const hosts = [
|
|
{
|
|
host: 'internal',
|
|
hostname: '10.0.0.5',
|
|
identityFile: '/home/user/.ssh/id_rsa',
|
|
identityAgent: '/home/user/.1password/agent.sock',
|
|
identitiesOnly: true,
|
|
proxyCommand: 'ssh -W %h:%p bastion',
|
|
proxyUseFdpass: true,
|
|
proxyJump: 'bastion.example.com'
|
|
}
|
|
]
|
|
const targets = sshConfigHostsToTargets(hosts, new Set())
|
|
expect(targets[0].identityFile).toBe('/home/user/.ssh/id_rsa')
|
|
expect(targets[0].identityAgent).toBe('/home/user/.1password/agent.sock')
|
|
expect(targets[0].identitiesOnly).toBe(true)
|
|
expect(targets[0].proxyCommand).toBe('ssh -W %h:%p bastion')
|
|
expect(targets[0].jumpHost).toBe('bastion.example.com')
|
|
})
|
|
|
|
it('carries through gssapiAuthentication', () => {
|
|
const hosts = [{ host: 'krb-host', hostname: 'krb.example.com', gssapiAuthentication: true }]
|
|
const targets = sshConfigHostsToTargets(hosts, new Set())
|
|
expect(targets[0].gssapiAuthentication).toBe(true)
|
|
})
|
|
|
|
it('imports duplicate aliases only once and keeps the first concrete host', () => {
|
|
const hosts = [
|
|
{ host: 'dup', hostname: 'first.example.com', user: 'first' },
|
|
{ host: 'dup', hostname: 'second.example.com', user: 'second' }
|
|
]
|
|
const targets = sshConfigHostsToTargets(hosts, new Set())
|
|
|
|
expect(targets).toHaveLength(1)
|
|
expect(targets[0]).toMatchObject({
|
|
label: 'dup',
|
|
host: 'first.example.com',
|
|
username: 'first'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('sshConfigHostsToSummaries', () => {
|
|
it('maps hosts for the picker and flags aliases already in Orca', () => {
|
|
const hosts = [
|
|
{
|
|
host: 'staging',
|
|
hostname: 'staging.internal',
|
|
user: 'ubuntu',
|
|
port: 22,
|
|
identityFile: '/home/me/.ssh/staging'
|
|
},
|
|
{ host: 'prod', hostname: 'prod.example', user: 'ops', port: 2222, proxyJump: 'bastion' }
|
|
]
|
|
const summaries = sshConfigHostsToSummaries(hosts, [
|
|
{ label: 'staging', configHost: 'staging' }
|
|
])
|
|
expect(summaries).toEqual([
|
|
{
|
|
alias: 'staging',
|
|
hostname: 'staging.internal',
|
|
port: 22,
|
|
username: 'ubuntu',
|
|
identityFile: '/home/me/.ssh/staging',
|
|
alreadyInOrca: true
|
|
},
|
|
{
|
|
alias: 'prod',
|
|
hostname: 'prod.example',
|
|
port: 2222,
|
|
username: 'ops',
|
|
jumpHost: 'bastion',
|
|
alreadyInOrca: false
|
|
}
|
|
])
|
|
})
|
|
|
|
it('de-dupes aliases and defaults missing fields', () => {
|
|
const hosts = [
|
|
{ host: 'box' },
|
|
{ host: 'box', hostname: 'second.example' },
|
|
{ host: 'other', user: 'me' }
|
|
]
|
|
const summaries = sshConfigHostsToSummaries(hosts, [])
|
|
expect(summaries).toHaveLength(2)
|
|
expect(summaries[0]).toEqual({
|
|
alias: 'box',
|
|
hostname: 'box',
|
|
port: 22,
|
|
username: '',
|
|
alreadyInOrca: false
|
|
})
|
|
expect(summaries[1].alias).toBe('other')
|
|
})
|
|
})
|
|
|
|
// ── parseSshGOutput ──────────────────────────────────────────────────
|
|
|
|
describe('parseSshGOutput', () => {
|
|
it('parses hostname, user, port from ssh -G output', () => {
|
|
const output = [
|
|
'hostname 192.168.1.100',
|
|
'user deploy',
|
|
'port 2222',
|
|
'identityfile /home/testuser/.ssh/id_ed25519',
|
|
'forwardagent no'
|
|
].join('\n')
|
|
|
|
const result = parseSshGOutput(output)
|
|
expect(result.hostname).toBe('192.168.1.100')
|
|
expect(result.user).toBe('deploy')
|
|
expect(result.port).toBe(2222)
|
|
expect(result.identityFile).toEqual(['/home/testuser/.ssh/id_ed25519'])
|
|
expect(result.forwardAgent).toBe(false)
|
|
})
|
|
|
|
it('collects multiple identity files', () => {
|
|
const output = [
|
|
'hostname example.com',
|
|
'identityfile ~/.ssh/id_ed25519',
|
|
'identityfile ~/.ssh/id_rsa',
|
|
'port 22'
|
|
].join('\n')
|
|
|
|
const result = parseSshGOutput(output)
|
|
expect(result.identityFile).toEqual([
|
|
testHomePath('.ssh', 'id_ed25519'),
|
|
testHomePath('.ssh', 'id_rsa')
|
|
])
|
|
})
|
|
|
|
it('parses forwardagent yes', () => {
|
|
const output = 'hostname example.com\nforwardagent yes\nport 22'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.forwardAgent).toBe(true)
|
|
})
|
|
|
|
it('defaults port to 22 when missing', () => {
|
|
const output = 'hostname example.com'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.port).toBe(22)
|
|
})
|
|
|
|
it('returns empty hostname when missing', () => {
|
|
const output = 'user admin\nport 22'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.hostname).toBe('')
|
|
})
|
|
|
|
it('returns undefined user when missing', () => {
|
|
const output = 'hostname example.com\nport 22'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.user).toBeUndefined()
|
|
})
|
|
|
|
it('handles empty output', () => {
|
|
const result = parseSshGOutput('')
|
|
expect(result.hostname).toBe('')
|
|
expect(result.port).toBe(22)
|
|
expect(result.identityFile).toEqual([])
|
|
})
|
|
|
|
it('skips lines without spaces', () => {
|
|
const output = 'hostname example.com\nbadline\nport 22'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.hostname).toBe('example.com')
|
|
expect(result.port).toBe(22)
|
|
})
|
|
|
|
it('parses proxycommand and filters "none"', () => {
|
|
const output = 'hostname example.com\nproxycommand ssh -W %h:%p bastion\nport 22'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.proxyCommand).toBe('ssh -W %h:%p bastion')
|
|
|
|
const noneOutput = 'hostname example.com\nproxycommand none\nport 22'
|
|
const noneResult = parseSshGOutput(noneOutput)
|
|
expect(noneResult.proxyCommand).toBeUndefined()
|
|
})
|
|
|
|
it('parses proxyusefdpass yes', () => {
|
|
const output = 'hostname example.com\nproxyusefdpass yes\nport 22'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.proxyUseFdpass).toBe(true)
|
|
|
|
const noneOutput = 'hostname example.com\nproxyusefdpass no\nport 22'
|
|
const noneResult = parseSshGOutput(noneOutput)
|
|
expect(noneResult.proxyUseFdpass).toBe(false)
|
|
})
|
|
|
|
it('parses proxyjump and filters "none"', () => {
|
|
const output = 'hostname example.com\nproxyjump bastion.example.com\nport 22'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.proxyJump).toBe('bastion.example.com')
|
|
|
|
const noneOutput = 'hostname example.com\nproxyjump none\nport 22'
|
|
const noneResult = parseSshGOutput(noneOutput)
|
|
expect(noneResult.proxyJump).toBeUndefined()
|
|
})
|
|
|
|
it('handles ~ expansion in identity file paths', () => {
|
|
const output = 'hostname example.com\nidentityfile ~/custom_key\nport 22'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.identityFile).toEqual([testHomePath('custom_key')])
|
|
})
|
|
|
|
it('handles Windows-style ~ expansion in identity file paths', () => {
|
|
const output = 'hostname example.com\nidentityfile ~\\.ssh\\custom_key\nport 22'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.identityFile).toEqual([testHomePath('.ssh', 'custom_key')])
|
|
})
|
|
|
|
it('parses identityagent with ~ expansion', () => {
|
|
const output = 'hostname example.com\nidentityagent ~/.1password/agent.sock\nport 22'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.identityAgent).toBe(testHomePath('.1password', 'agent.sock'))
|
|
})
|
|
|
|
it('preserves identityagent none so auth can disable agent fallback', () => {
|
|
const output = 'hostname example.com\nidentityagent none\nport 22'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.identityAgent).toBe('none')
|
|
})
|
|
|
|
it('parses identitiesonly yes', () => {
|
|
const output = 'hostname example.com\nidentitiesonly yes\nport 22'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.identitiesOnly).toBe(true)
|
|
})
|
|
|
|
it('parses gssapiauthentication', () => {
|
|
const output = 'hostname example.com\ngssapiauthentication yes\nport 22'
|
|
const result = parseSshGOutput(output)
|
|
expect(result.gssapiAuthentication).toBe(true)
|
|
|
|
const offResult = parseSshGOutput('hostname example.com\ngssapiauthentication no\nport 22')
|
|
expect(offResult.gssapiAuthentication).toBe(false)
|
|
})
|
|
|
|
it('parses controlmaster options and filters controlpath none', () => {
|
|
const output = [
|
|
'hostname example.com',
|
|
'controlmaster auto',
|
|
'controlpath ~/.ssh/cm/%r@%h:%p',
|
|
'controlpersist 10m',
|
|
'port 22'
|
|
].join('\n')
|
|
const result = parseSshGOutput(output)
|
|
expect(result.controlMaster).toBe('auto')
|
|
expect(result.controlPath).toBe(testHomePath('.ssh', 'cm', '%r@%h:%p'))
|
|
expect(result.controlPersist).toBe('10m')
|
|
|
|
const noneResult = parseSshGOutput(
|
|
'hostname example.com\ncontrolmaster no\ncontrolpath none\ncontrolpersist no\nport 22'
|
|
)
|
|
expect(noneResult.controlMaster).toBe('no')
|
|
expect(noneResult.controlPath).toBeUndefined()
|
|
expect(noneResult.controlPersist).toBe('no')
|
|
})
|
|
})
|