Files
orca/src/main/ssh/ssh-connection-store.test.ts
T
Jinjing cda2280d63 Show all automations (#16532)
* Add all-host automations with scoped ownership and multi-authority suppo

Enable automations to run on multiple hosts (SSH targets and local) with
owner-fenced mutations, scoped list queries per host, and conflict
resolution. Introduces desktop and runtime authorities as distinct
automation storage owners, with per-host caching, invalidation, and
retry scheduling on the renderer. Captures registration generations for
SSH hosts to survive re-adoption. Adds CLI support for destination
selection and conflict recovery.

* Filter automation create projects by destination host

Only offer projects available on the selected destination, preventing
the mismatches that would fail at submit time. Auto-adjust the project
selection if it becomes unavailable when the destination changes.

* Add runtime storage authority support for automations

- Support both runtime and desktop as automation storage authorities
- Make owner preconditions optional for legacy-client compatibility
- Cache automation list projections to improve performance
- Add per-row repo/worktree resolution for cross-authority collisions
- Extend automation.list RPC to always include owner metadata

* Replace child_process.execFile with runProcess for external automations

- Migrate external-manager to use cross-platform runProcess wrapper per child-process safety policy
- Abstract electron app/ipcMain APIs in orca-runtime via environment accessors
- Install fake app environment in automation tests for consistent setup
- Reorganize imports to use specific module paths (ssh-target-registry, agent-detection, browser-error)
- Remove external-manager from child-process import allowlists (no longer violates direct import)

* Unify desktop automation CRUD onto the local runtime RPC surface

The desktop authority now speaks the same automation.* RPC contract as
remote runtimes, via callRuntimeRpc({kind:'local'}) -> runtime:call ->
the shared RpcDispatcher. The automations:list/listRuns/create/update/
delete/runNow IPC arms, their preload members, and every renderer
desktop-vs-runtime transport fork are retired; the runtime methods are
the single implementation of scoped lists, owner fencing, and change
publication for both transports (mobile clients already exercised them).

The desktop probe scheduler's priority lease survives the move as an
AutomationService hook the IPC registration installs and the runtime
methods take, so Orca's own automation traffic still parks queued
external-manager probes.

External-manager scope arms and dispatch-loop plumbing stay on IPC by
design; automation change events keep their existing channels (renderer
ingestion already converges them by authority).

* Remove automation ghost SSH tombstone scanning

This functionality for synthesizing tombstones for automation-referenced SSH
targets is no longer needed as part of the automation system refactoring.

* Refuse orphan automations at dispatch time, not migration time

Remove migration-time disabling of orphan automations and the `enabledDecidedBy` field. Dispatch now refuses orphans at runtime instead, simplifying state management and UI. Orphans are left unstamped and enabled; dispatch refuses to run them via `resolveAutomationRunTarget`.

* Show all automations in flat table with unified filter menu

- Replace host picker component with comprehensive Filters menu supporting status, last run, agent, and host filters
- Flatten automation list layout to single table instead of host-grouped sections
- Add Host column to display execution host for each automation
- Display active filters as removable pills below toolbar
- Delete unused AutomationHostPicker* components

* Add automation owner fencing and destination validation

- New AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY for owner preconditions; legacy clients get owner metadata snapshotted at RPC boundary for compatibility
- Editor captures and revalidates automation destination before save, preventing silent retargeting if SSH infrastructure changes mid-edit
- SSH target types now isolate renderer-authored fields; generation is server-owned and stripped by IPC handlers

* Route automation recovery actions to the origin host

When an automation action fails due to owner fencing, recovery verbs
("Update server", "Reconnect") must run on the host where the refusal
originated: the row's captured owner for row operations, or the
destination the create dialog captured, not the list's filtered host.

* Remove external manager scope limitation notices

Consolidate create destination eligibility checks with a unified predicate
and fix the bug where desktop repo IDs could be sent to runtime hosts where
they cannot resolve.

* Persist only store-derived automation contexts, not client-perspective o

Store contexts must never be based on client-provided runContext or sourceContext
values—clients speak a different perspective (e.g., 'runtime:<id>' for host IDs
they assign), and persisting those makes the store projection orphan automations
it actually owns. Derived contexts now take precedence in create and update paths,
with explicit null still honored to clear a value. Tests verify this by simulating
drift after storage and confirming that moves re-derive while toggles preserve.
2026-08-26 09:50:12 -07:00

748 lines
24 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from 'vitest'
import { SshConnectionStore } from './ssh-connection-store'
import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types'
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
loadUserSshConfigMock: vi.fn(),
sshConfigHostsToTargetsMock: vi.fn()
}))
vi.mock('./ssh-config-parser', () => ({
loadUserSshConfig: loadUserSshConfigMock,
sshConfigHostsToTargets: sshConfigHostsToTargetsMock
}))
function createMockStore() {
const targets: SshTarget[] = []
let deletedAliases: string[] = []
const removedTombstones: RemovedSshTargetTombstone[] = []
const reassignments: { oldTargetId: string; newTargetId: string }[] = []
let generationCounter = 0
const dropTombstone = (oldTargetId: string) => {
const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId)
removedTombstones.length = 0
removedTombstones.push(...kept)
}
return {
allocateSshTargetGeneration: vi.fn(() => {
generationCounter += 1
return generationCounter
}),
getSshTargets: vi.fn(() => [...targets]),
getSshTarget: vi.fn((id: string) => targets.find((t) => t.id === id)),
addSshTarget: vi.fn((target: SshTarget) => targets.push(target)),
updateSshTarget: vi.fn((id: string, updates: Partial<Omit<SshTarget, 'id'>>) => {
const target = targets.find((t) => t.id === id)
if (!target) {
return null
}
Object.assign(target, updates)
return { ...target }
}),
removeSshTarget: vi.fn((id: string) => {
const idx = targets.findIndex((t) => t.id === id)
if (idx !== -1) {
targets.splice(idx, 1)
}
}),
getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]),
addDeletedSshConfigAlias: vi.fn((alias: string) => {
if (!deletedAliases.includes(alias)) {
deletedAliases.push(alias)
}
}),
removeDeletedSshConfigAlias: vi.fn((alias: string) => {
deletedAliases = deletedAliases.filter((entry) => entry !== alias)
}),
clearDeletedSshConfigAliases: vi.fn(() => {
deletedAliases = []
}),
removedTombstones,
reassignments,
getRemovedSshTargetTombstones: vi.fn(() => [...removedTombstones]),
addRemovedSshTargetTombstone: vi.fn((tombstone: RemovedSshTargetTombstone) => {
const filtered = removedTombstones.filter((t) => t.oldTargetId !== tombstone.oldTargetId)
removedTombstones.length = 0
removedTombstones.push(...filtered, tombstone)
}),
removeRemovedSshTargetTombstone: vi.fn(dropTombstone),
// Nothing in this mock stores automations, so releasing always drops.
releaseRemovedSshTargetTombstone: vi.fn(dropTombstone),
reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => {
reassignments.push({ oldTargetId, newTargetId })
// Pretend one repo referenced the old id.
return ['repo-1']
})
}
}
describe('SshConnectionStore', () => {
let mockStore: ReturnType<typeof createMockStore>
let sshStore: SshConnectionStore
beforeEach(() => {
mockStore = createMockStore()
sshStore = new SshConnectionStore(mockStore as never)
loadUserSshConfigMock.mockReset()
sshConfigHostsToTargetsMock.mockReset()
})
it('listTargets delegates to store', () => {
sshStore.listTargets()
expect(mockStore.getSshTargets).toHaveBeenCalled()
})
it('lists picker suppression aliases without consulting re-adoption tombstones', () => {
mockStore.addDeletedSshConfigAlias('config-removed')
mockStore.addRemovedSshTargetTombstone({
oldTargetId: 'ssh-manual',
configHost: 'manual-removed',
host: 'manual.internal',
port: 22,
username: 'deploy',
label: 'Manual',
removedAt: 1
})
expect(sshStore.listSuppressedSshConfigAliases()).toEqual(['config-removed'])
expect(mockStore.getRemovedSshTargetTombstones).not.toHaveBeenCalled()
})
it('getTarget delegates to store', () => {
sshStore.getTarget('test-id')
expect(mockStore.getSshTarget).toHaveBeenCalledWith('test-id')
})
it('addTarget generates an id and persists', () => {
const target = sshStore.addTarget({
label: 'My Server',
host: 'example.com',
port: 22,
username: 'deploy'
})
expect(target.id).toMatch(/^ssh-/)
expect(target.label).toBe('My Server')
expect(mockStore.addSshTarget).toHaveBeenCalledWith(target)
})
it('addTarget stamps source as manual by default', () => {
const target = sshStore.addTarget({
label: 'My Server',
host: 'example.com',
port: 22,
username: 'deploy'
})
expect(target.source).toBe('manual')
})
it('addTarget preserves an explicitly provided source', () => {
const target = sshStore.addTarget({
label: 'My Server',
host: 'example.com',
port: 22,
username: 'deploy',
source: 'ssh-config'
})
expect(target.source).toBe('ssh-config')
})
it('hides runtime-owned targets from normal target lists', () => {
const userTarget = sshStore.addTarget({
label: 'My Server',
host: 'example.com',
port: 22,
username: 'deploy'
})
sshStore.upsertRuntimeOwnedTarget('runtime-1', {
label: 'Sandbox',
host: 'sandbox.example.com',
port: 22,
username: 'root'
})
expect(sshStore.listTargets()).toEqual([userTarget])
})
it('updateTarget delegates to store', () => {
const original: SshTarget = {
id: 'ssh-1',
label: 'Old Name',
host: 'example.com',
port: 22,
username: 'user'
}
mockStore.addSshTarget(original)
const result = sshStore.updateTarget('ssh-1', { label: 'New Name' })
expect(result).toBeTruthy()
expect(mockStore.updateSshTarget).toHaveBeenCalledWith('ssh-1', { label: 'New Name' })
})
it('removeTarget delegates to store', () => {
sshStore.removeTarget('ssh-1')
expect(mockStore.removeSshTarget).toHaveBeenCalledWith('ssh-1')
})
describe('importFromSshConfig', () => {
function candidate(overrides: Partial<SshTarget> & { configHost: string }): SshTarget {
return {
id: `tmp-${overrides.configHost}`,
label: overrides.configHost,
host: `${overrides.configHost}.example.com`,
port: 22,
username: '',
...overrides
}
}
it('inserts a new config host stamped as ssh-config', () => {
loadUserSshConfigMock.mockReturnValue([{ host: 'staging' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({ configHost: 'staging', host: 'staging.example.com' })
])
const result = sshStore.importFromSshConfig()
expect(mockStore.addSshTarget).toHaveBeenCalledWith(
expect.objectContaining({ configHost: 'staging', source: 'ssh-config' })
)
expect(result).toHaveLength(1)
expect(result[0].source).toBe('ssh-config')
})
it('asks the parser for all hosts — reconciliation happens in the store', () => {
loadUserSshConfigMock.mockReturnValue([{ host: 'a' }])
sshConfigHostsToTargetsMock.mockReturnValue([])
sshStore.importFromSshConfig()
expect(sshConfigHostsToTargetsMock).toHaveBeenCalledWith([{ host: 'a' }], new Set())
})
// PRIMARY regression (#4684 item #1): a rotated port must take effect on
// re-import instead of silently keeping the stale value.
it('updates an existing config-sourced target when the port changed', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'cluster',
configHost: 'cluster',
host: '10.0.0.5',
port: 2200,
username: 'dev',
source: 'ssh-config'
})
loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({ configHost: 'cluster', host: '10.0.0.5', port: 2222, username: 'dev' })
])
const result = sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).toHaveBeenCalledWith(
'ssh-1',
expect.objectContaining({ port: 2222, source: 'ssh-config' })
)
// Only the seed insert — no duplicate target created.
expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1)
expect(result).toHaveLength(1)
expect(result[0].port).toBe(2222)
})
it('refreshes host, username, and jump host on sync, not just port', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'box',
configHost: 'box',
host: 'old.example.com',
port: 22,
username: 'old',
source: 'ssh-config'
})
loadUserSshConfigMock.mockReturnValue([{ host: 'box' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({
configHost: 'box',
host: 'new.example.com',
port: 2200,
username: 'newuser',
jumpHost: 'bastion'
})
])
sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).toHaveBeenCalledWith(
'ssh-1',
expect.objectContaining({
host: 'new.example.com',
port: 2200,
username: 'newuser',
jumpHost: 'bastion'
})
)
})
it('refreshes gssapiAuthentication on sync', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'krb-box',
configHost: 'krb-box',
host: 'krb.example.com',
port: 22,
username: 'dev',
source: 'ssh-config'
})
loadUserSshConfigMock.mockReturnValue([{ host: 'krb-box' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({
configHost: 'krb-box',
host: 'krb.example.com',
username: 'dev',
gssapiAuthentication: true
})
])
sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).toHaveBeenCalledWith(
'ssh-1',
expect.objectContaining({ gssapiAuthentication: true })
)
})
it('never overwrites a manual target that owns the alias', () => {
mockStore.addSshTarget({
id: 'ssh-m',
label: 'cluster',
configHost: 'cluster',
host: 'manual.example.com',
port: 22,
username: 'me',
source: 'manual'
})
loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({ configHost: 'cluster', host: '10.0.0.9', port: 2222, username: 'dev' })
])
const result = sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).not.toHaveBeenCalled()
// Only the manual seed insert — the config alias is not duplicated.
expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1)
expect(result).toEqual([])
})
it('adopts a legacy unsourced target into config-sync', () => {
mockStore.addSshTarget({
id: 'ssh-legacy',
label: 'cluster',
configHost: 'cluster',
host: '10.0.0.5',
port: 2200,
username: 'dev'
// no source — predates the field
})
loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({ configHost: 'cluster', host: '10.0.0.5', port: 2222, username: 'dev' })
])
const result = sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).toHaveBeenCalledWith(
'ssh-legacy',
expect.objectContaining({ port: 2222, source: 'ssh-config' })
)
expect(result[0].source).toBe('ssh-config')
})
it('does not overwrite a legacy unsourced manual target with the same alias', () => {
mockStore.addSshTarget({
id: 'ssh-legacy-manual',
label: 'cluster',
configHost: 'cluster',
host: 'cluster',
port: 2200,
username: 'me'
// no source — predates the field, but does not look like a config import
})
loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({ configHost: 'cluster', host: '10.0.0.5', port: 2222, username: 'dev' })
])
const result = sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).not.toHaveBeenCalled()
expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1)
expect(result).toEqual([])
})
// SSH matches Host patterns case-insensitively, so `Prod` and `prod` are one host
// everywhere else in the picker — import ownership must agree.
it('treats a case-only alias variant as owned by the existing manual target', () => {
mockStore.addSshTarget({
id: 'ssh-m',
label: 'Prod',
configHost: 'Prod',
host: 'manual.example.com',
port: 22,
username: 'me',
source: 'manual'
})
loadUserSshConfigMock.mockReturnValue([{ host: 'prod' }])
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'prod' })])
const result = sshStore.importFromSshConfig()
expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1)
expect(result).toEqual([])
})
it('keeps a case-only alias variant suppressed after the host was deleted', () => {
const added = sshStore.addTarget({
label: 'Prod',
configHost: 'Prod',
host: 'prod.example.com',
port: 22,
username: 'me'
})
sshStore.removeTarget(added.id)
loadUserSshConfigMock.mockReturnValue([{ host: 'prod' }])
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'prod' })])
const result = sshStore.importFromSshConfig()
expect(result).toEqual([])
expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1)
})
it('lifts a tombstone stored under different casing when the host is re-added', () => {
const added = sshStore.addTarget({
label: 'Prod',
configHost: 'Prod',
host: 'prod.example.com',
port: 22,
username: 'me'
})
sshStore.removeTarget(added.id)
sshStore.addTarget({
label: 'prod',
configHost: 'prod',
host: 'prod.example.com',
port: 22,
username: 'me'
})
expect(sshStore.listSuppressedSshConfigAliases()).toEqual([])
})
it('does not rewrite an unchanged config-sourced target', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'cluster',
configHost: 'cluster',
host: 'cluster.example.com',
port: 22,
username: '',
source: 'ssh-config'
})
loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }])
// Candidate is identical to the persisted target (same default fields).
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'cluster' })])
const result = sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).not.toHaveBeenCalled()
expect(result).toEqual([])
})
it('returns empty array when nothing changed', () => {
loadUserSshConfigMock.mockReturnValue([])
sshConfigHostsToTargetsMock.mockReturnValue([])
const result = sshStore.importFromSshConfig()
expect(result).toEqual([])
})
})
describe('deleted config host tombstones', () => {
function candidate(overrides: Partial<SshTarget> & { configHost: string }): SshTarget {
return {
id: `tmp-${overrides.configHost}`,
label: overrides.configHost,
host: `${overrides.configHost}.example.com`,
port: 22,
username: '',
...overrides
}
}
// PRIMARY regression: deleting a config-sourced host must not let the next
// ~/.ssh/config sync resurrect it.
it('tombstones a deleted config-sourced host so re-import skips it', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'mini',
configHost: 'mini',
host: 'mini.example.com',
port: 22,
username: 'ping',
source: 'ssh-config'
})
sshStore.removeTarget('ssh-1')
expect(mockStore.addDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
const result = sshStore.importFromSshConfig()
expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) // only the seed insert
expect(result).toEqual([])
})
it('suppresses a deleted manual target from config discovery', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'mini',
configHost: 'mini',
host: 'mini.example.com',
port: 22,
username: 'ping',
source: 'manual'
})
sshStore.removeTarget('ssh-1')
expect(mockStore.addDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
})
it('re-adding a deleted host reclaims its alias so sync stops suppressing it', () => {
mockStore.addDeletedSshConfigAlias('mini')
sshStore.addTarget({
label: 'mini',
configHost: 'mini',
host: '10.0.0.2',
port: 22,
username: 'ping'
})
expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
// Alias reclaimed, but it is now a manual target — still not re-inserted.
const result = sshStore.importFromSshConfig()
expect(result).toEqual([])
})
it('editing a target reclaims its alias from the deleted set', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'mini',
configHost: 'mini',
host: 'mini.example.com',
port: 22,
username: 'ping',
source: 'ssh-config'
})
mockStore.addDeletedSshConfigAlias('mini')
sshStore.updateTarget('ssh-1', { port: 2222, source: 'manual' })
expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
})
it('reAdopt clears all tombstones and re-imports the deleted host', () => {
mockStore.addDeletedSshConfigAlias('mini')
loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
const result = sshStore.importFromSshConfig({ reAdopt: true })
expect(mockStore.clearDeletedSshConfigAliases).toHaveBeenCalled()
expect(mockStore.addSshTarget).toHaveBeenCalledWith(
expect.objectContaining({ configHost: 'mini', source: 'ssh-config' })
)
expect(result).toHaveLength(1)
})
it('reports every exact repo migration from a multi-host re-import', () => {
mockStore.addRemovedSshTargetTombstone({
oldTargetId: 'ssh-old-a',
configHost: 'host-a',
host: 'host-a.example.com',
port: 22,
username: '',
label: 'host-a',
removedAt: 1
})
mockStore.addRemovedSshTargetTombstone({
oldTargetId: 'ssh-old-b',
configHost: 'host-b',
host: 'host-b.example.com',
port: 22,
username: '',
label: 'host-b',
removedAt: 1
})
loadUserSshConfigMock.mockReturnValue([{ host: 'host-a' }, { host: 'host-b' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({ configHost: 'host-a' }),
candidate({ configHost: 'host-b' })
])
sshStore.importFromSshConfig({ reAdopt: true })
expect(sshStore.lastRepoReadoptions).toEqual([
{ oldTargetId: 'ssh-old-a', newTargetId: 'tmp-host-a', repoIds: ['repo-1'] },
{ oldTargetId: 'ssh-old-b', newTargetId: 'tmp-host-b', repoIds: ['repo-1'] }
])
})
})
describe('re-adoption of orphaned workspaces', () => {
it('records a tombstone when removing any user-facing target', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'Dev',
host: 'dev.example.com',
port: 22,
username: 'tim',
source: 'manual'
})
sshStore.removeTarget('ssh-1')
expect(mockStore.addRemovedSshTargetTombstone).toHaveBeenCalledWith(
expect.objectContaining({
oldTargetId: 'ssh-1',
host: 'dev.example.com',
port: 22,
username: 'tim'
})
)
})
it('does not tombstone runtime-owned targets', () => {
mockStore.addSshTarget({
id: 'runtime-ssh-abc',
label: 'VM',
host: 'vm.example.com',
port: 22,
username: 'tim',
owner: { type: 'on-demand-runtime', runtimeId: 'abc' }
})
sshStore.removeTarget('runtime-ssh-abc')
expect(mockStore.addRemovedSshTargetTombstone).not.toHaveBeenCalled()
expect(mockStore.addDeletedSshConfigAlias).not.toHaveBeenCalled()
})
it('re-adopts orphaned repos when the same host is re-added', () => {
// Simulate a prior removal by seeding a matching tombstone.
mockStore.addRemovedSshTargetTombstone({
oldTargetId: 'ssh-old',
host: 'dev.example.com',
port: 22,
username: 'tim',
label: 'Dev',
removedAt: 1
})
sshStore.addTarget({
label: 'Dev',
host: 'dev.example.com',
port: 22,
username: 'tim'
})
expect(mockStore.reassignSshTargetId).toHaveBeenCalledTimes(1)
const [oldId, newId] = mockStore.reassignSshTargetId.mock.calls[0]
expect(oldId).toBe('ssh-old')
expect(newId).toMatch(/^ssh-/)
expect(sshStore.lastRepoReadoptions).toEqual([
{ oldTargetId: 'ssh-old', newTargetId: newId, repoIds: ['repo-1'] }
])
})
// Why: drive the real remove→re-add path so the tombstone carries the
// defaulted configHost (host) that buildRemovedSshTargetTombstone produces,
// rather than a hand-seeded tombstone without one.
it('re-adopts through an actual removeTarget then re-add of the same host', () => {
const added = sshStore.addTarget({
label: 'Dev',
host: 'dev.example.com',
port: 22,
username: 'tim'
})
sshStore.removeTarget(added.id)
// The tombstone was built from the real target (configHost defaulted to host).
const tombstones = mockStore.getRemovedSshTargetTombstones()
expect(tombstones).toHaveLength(1)
expect(tombstones[0]).toMatchObject({ oldTargetId: added.id, configHost: 'dev.example.com' })
mockStore.reassignSshTargetId.mockClear()
const readded = sshStore.addTarget({
label: 'Dev',
host: 'dev.example.com',
port: 22,
username: 'tim'
})
expect(mockStore.reassignSshTargetId).toHaveBeenCalledWith(added.id, readded.id)
expect(sshStore.lastRepoReadoptions).toEqual([
{ oldTargetId: added.id, newTargetId: readded.id, repoIds: ['repo-1'] }
])
})
// A different account on the SAME host must NOT re-adopt, even though both
// manual adds default configHost to the shared hostname.
it('does not re-adopt a different account on the same host', () => {
const alice = sshStore.addTarget({
label: 'alice',
host: 'dev.example.com',
port: 22,
username: 'alice'
})
sshStore.removeTarget(alice.id)
mockStore.reassignSshTargetId.mockClear()
sshStore.addTarget({
label: 'bob',
host: 'dev.example.com',
port: 2222,
username: 'bob'
})
expect(mockStore.reassignSshTargetId).not.toHaveBeenCalled()
})
it('does not re-adopt when the re-added host identity differs', () => {
mockStore.addRemovedSshTargetTombstone({
oldTargetId: 'ssh-old',
host: 'other.example.com',
port: 22,
username: 'root',
label: 'Other',
removedAt: 1
})
sshStore.addTarget({
label: 'Dev',
host: 'dev.example.com',
port: 22,
username: 'tim'
})
expect(mockStore.reassignSshTargetId).not.toHaveBeenCalled()
expect(sshStore.lastRepoReadoptions).toEqual([])
})
})
})