Files
orca/src/main/ipc/ssh.test.ts
T
6837a4c180 fix(ssh): a relay-era desktop can join the server another desktop on the same login runs (#28886)
* fix(ssh): a relay-era desktop can join the server another desktop on the same login runs

A desktop that still had relay-era projects on a host whose managed server
another Orca version ran could never connect: its migration skipped the
running-version adoption, so it deferred on a terminal count only a paired
server can give. Once the versions matched it failed instead because the
server already held the same repository path, added by the other desktop.

The migration now pairs with the running server like a fresh desktop does,
before any upload. Projects the server already holds at the same host path
are linked to the server's project instead of imported twice: the journal
is re-cut without them, under the same migration id, only after the server
proves it holds nothing of the earlier manifest. A conflict the server will
keep refusing now says what conflicts and how to get past it.

* fix(ssh): never link a project a bridged relay terminal still runs in

The bridge reattaches a relay pane only into its own tab, which a linked
project never sends to the server, so linking would leave that terminal
running out of sight. Refuse the link instead, naming the project and how
to end the terminals.

* test(ssh): run the link test on Node's SQLite runtime and read the host's activation record

* test(ssh): establish desktop B's profile before seeding its relay-era state

* test(ssh): connect desktop B's host explicitly in the shared-login spec

* fix(ssh): say what a linked project left behind, and never blame it for an unowned relay terminal

A project this desktop moved that the host's server already held is linked to the
server's copy, so its tabs and automations from this desktop stay behind. The
connect now carries those linked projects and the SSH host card says so, with the
automation count, instead of dropping them silently.

A bridged relay terminal of unknown or removed project still blocks a link, but its
guidance no longer names the linked project as where it runs. Both terminal
messages now offer removing the project from this desktop as the way out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:48:25 -07:00

145 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = await vi.hoisted(async () => {
const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks')
return createSshIpcMocks()
})
vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker)
vi.mock('electron', () => mocks.electron)
vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore)
vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect)
vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager)
vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer)
vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward)
vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner)
import type { SshConnectionState, SshTarget } from '../../shared/ssh-types'
import { createSshIpcHarness } from './ssh-ipc-test-harness'
import {
decideHostServer,
publishManagedServerConnect,
publishUnservedHostServer
} from './ssh-host-server-connect'
import { beginSshHostCensus, recordSshConnectionOpened } from '../ssh/ssh-connection-attribution'
import type { SshConnection } from '../ssh/ssh-connection'
const { mockSshStore, mockConnectionManager } = mocks
const target: SshTarget = {
id: 'ssh-1',
label: 'Server',
host: 'example.com',
port: 22,
username: 'deploy'
}
function asTransport(value: { id: string }): SshConnection {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attribution keys on identity only.
return value as unknown as SshConnection
}
describe('SSH IPC handlers', () => {
const harness = createSshIpcHarness(mocks)
const { handlers, mockWindow } = harness
beforeEach(harness.reset)
it('ssh:connect throws for unknown targetId', async () => {
mockSshStore.getTarget.mockReturnValue(undefined)
await expect(handlers.get('ssh:connect')!(null, { targetId: 'unknown' })).rejects.toThrow(
'SSH target "unknown" not found'
)
})
it('publishes a managed connect without opening a relay', async () => {
mockSshStore.getTarget.mockReturnValue(target)
await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toMatchObject({
status: 'connected'
})
expect(publishManagedServerConnect).toHaveBeenCalledWith(
'ssh-1',
'env-test',
undefined,
undefined,
undefined
)
expect(mockConnectionManager.connect).not.toHaveBeenCalled()
})
it('fails a host the managed server does not serve, with no fallback', async () => {
const decision = {
route: 'unserved' as const,
reason: 'orcad_unavailable' as const,
detail: 'unsupported_host'
}
const opened = { id: 'decision-transport' }
mockSshStore.getTarget.mockReturnValue(target)
vi.mocked(decideHostServer).mockImplementationOnce(async () => {
recordSshConnectionOpened(asTransport(opened))
mockConnectionManager.getConnection.mockReturnValue(opened)
return decision
})
await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).rejects.toThrow(
'This SSH host isn’t supported'
)
expect(publishUnservedHostServer).toHaveBeenCalledWith(target, decision)
expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', opened)
expect(mockConnectionManager.connect).not.toHaveBeenCalled()
})
it('holds a raw connected the server decision causes', async () => {
const conn = {}
mockSshStore.getTarget.mockReturnValue(target)
mockConnectionManager.getConnection.mockReturnValue(conn)
let broadcastsDuringDecision: unknown[] = []
vi.mocked(decideHostServer).mockImplementationOnce(async () => {
await Promise.resolve()
const callbacks = mockConnectionManager.callbacksRef.current as {
onStateChange: (targetId: string, state: SshConnectionState) => void
}
callbacks.onStateChange('ssh-1', {
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
broadcastsDuringDecision = mockWindow.webContents.send.mock.calls
.filter(([channel]) => channel === 'ssh:state-changed')
.map(([, payload]) => payload)
return { route: 'managed', environmentId: 'env-test' }
})
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
expect(broadcastsDuringDecision).not.toContainEqual(
expect.objectContaining({ state: expect.objectContaining({ status: 'connected' }) })
)
expect(broadcastsDuringDecision.at(-1)).toMatchObject({ state: { status: 'connecting' } })
})
it("holds a census's raw 'connected' outside any connect", () => {
const end = beginSshHostCensus('ssh-1')
try {
const callbacks = mockConnectionManager.callbacksRef.current as {
onStateChange: (targetId: string, state: SshConnectionState) => void
}
mockWindow.webContents.send.mockClear()
callbacks.onStateChange('ssh-1', {
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
expect(mockWindow.webContents.send).toHaveBeenCalledWith(
'ssh:state-changed',
expect.objectContaining({ state: expect.objectContaining({ status: 'connecting' }) })
)
} finally {
end()
}
})
})