feat: add managed SSH execution spaces
Add managed SSH Hosts and projects with signed side-by-side GoodBuddy Agent and OpenCode Runtime installation, Ask and Execute workspace access, a Main-owned model bridge, detached Agent lifecycle, durable ACP output recovery, and project activation UI. Use protocol v2 with bounded FIFO frames over SSH instead of custom credit, priority, batching, or model-message chunking. Mismatched Host Agents now upgrade automatically, an exactly verified Node runtime is reused on-host, and local Portable builds can embed strictly verified Agent artifacts. Release note: 新增托管 SSH 远程项目,可自动安装或升级签名 Agent/OpenCode Runtime,并在 SSH 断线后继续 Agent 工作和同步未确认输出。
This commit is contained in:
@@ -0,0 +1,736 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SettingsCredentialCipher } from '../settings-credential-cipher'
|
||||
import {
|
||||
SshHostService,
|
||||
type SshHostLifecycleHooks
|
||||
} from './ssh-host-service'
|
||||
import { SshHostStore } from './ssh-host-store'
|
||||
import type {
|
||||
SshHostKeyCandidate,
|
||||
SshTransport
|
||||
} from './ssh-transport'
|
||||
|
||||
const roots: string[] = []
|
||||
const keyA: SshHostKeyCandidate = {
|
||||
algorithm: 'ssh-ed25519',
|
||||
publicKeyBase64: Buffer.from('key-a').toString('base64'),
|
||||
fingerprintSha256: `SHA256:${'A'.repeat(43)}`
|
||||
}
|
||||
const keyB: SshHostKeyCandidate = {
|
||||
algorithm: 'ssh-ed25519',
|
||||
publicKeyBase64: Buffer.from('key-b').toString('base64'),
|
||||
fingerprintSha256: `SHA256:${'B'.repeat(43)}`
|
||||
}
|
||||
const decrypt = vi.fn((value: Buffer) => value.toString())
|
||||
const cipher: SettingsCredentialCipher = {
|
||||
isAvailable: () => true,
|
||||
encrypt: (value) => Buffer.from(value),
|
||||
decrypt
|
||||
}
|
||||
const newPasswordHost = {
|
||||
name: 'Build host',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'password' as const,
|
||||
password: {
|
||||
action: 'replace' as const,
|
||||
value: 'private password'
|
||||
}
|
||||
}
|
||||
|
||||
async function createHarness(
|
||||
now = 1_000,
|
||||
lifecycleHooks: SshHostLifecycleHooks = {}
|
||||
): Promise<{
|
||||
service: SshHostService
|
||||
store: SshHostStore
|
||||
inspectHostKey: ReturnType<typeof vi.fn>
|
||||
testConnection: ReturnType<typeof vi.fn>
|
||||
setNow: (value: number) => void
|
||||
}> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ssh-service-'))
|
||||
roots.push(root)
|
||||
const store = new SshHostStore(join(root, 'ssh-hosts.json'), cipher)
|
||||
const inspectHostKey = vi.fn(async () => keyA)
|
||||
const testConnection = vi.fn(async () => ({
|
||||
connected: true as const,
|
||||
latencyMs: 12,
|
||||
platform: 'linux' as const,
|
||||
architecture: 'x64' as const,
|
||||
shell: '/bin/bash',
|
||||
homeDirectory: '/home/builder',
|
||||
detail: 'SSH 已连接,远端系统为 linux/x64'
|
||||
}))
|
||||
const transport: SshTransport = {
|
||||
inspectHostKey,
|
||||
testConnection
|
||||
}
|
||||
return {
|
||||
service: new SshHostService(
|
||||
store,
|
||||
transport,
|
||||
() => now,
|
||||
lifecycleHooks
|
||||
),
|
||||
store,
|
||||
inspectHostKey,
|
||||
testConnection,
|
||||
setNow: (value) => {
|
||||
now = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
await Promise.all(
|
||||
roots
|
||||
.splice(0)
|
||||
.map((root) => rm(root, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('SshHostService', () => {
|
||||
it('inspects a new draft before authentication without persisting it', async () => {
|
||||
const harness = await createHarness()
|
||||
|
||||
const inspection = await harness.service.inspectDraftHostKey({
|
||||
hostname: newPasswordHost.hostname,
|
||||
port: newPasswordHost.port,
|
||||
username: newPasswordHost.username
|
||||
})
|
||||
|
||||
expect(inspection).toMatchObject({
|
||||
state: 'unverified',
|
||||
algorithm: keyA.algorithm,
|
||||
fingerprintSha256: keyA.fingerprintSha256
|
||||
})
|
||||
expect(inspection.candidateId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
|
||||
)
|
||||
expect(harness.inspectHostKey).toHaveBeenCalledWith({
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder'
|
||||
})
|
||||
expect(harness.testConnection).not.toHaveBeenCalled()
|
||||
await expect(harness.service.getSnapshot()).resolves.toMatchObject({
|
||||
hosts: []
|
||||
})
|
||||
|
||||
await expect(
|
||||
harness.service.validateAndSave({
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyB.fingerprintSha256,
|
||||
input: newPasswordHost
|
||||
})
|
||||
).rejects.toThrow('指纹不匹配')
|
||||
await expect(harness.service.getSnapshot()).resolves.toMatchObject({
|
||||
hosts: []
|
||||
})
|
||||
|
||||
harness.setNow(301_000)
|
||||
await expect(
|
||||
harness.service.validateAndSave({
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
input: newPasswordHost
|
||||
})
|
||||
).rejects.toThrow('已过期')
|
||||
})
|
||||
|
||||
it('persists a host only after authentication and the probe succeed', async () => {
|
||||
const harness = await createHarness()
|
||||
const inspection = await harness.service.inspectDraftHostKey({
|
||||
hostname: newPasswordHost.hostname,
|
||||
port: newPasswordHost.port,
|
||||
username: newPasswordHost.username
|
||||
})
|
||||
harness.testConnection
|
||||
.mockRejectedValueOnce(new Error('SSH 认证失败'))
|
||||
.mockResolvedValueOnce({
|
||||
connected: true,
|
||||
latencyMs: 12,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
shell: '/bin/bash',
|
||||
homeDirectory: '/home/builder',
|
||||
detail: 'SSH 已连接,远端系统为 linux/x64'
|
||||
})
|
||||
|
||||
await expect(
|
||||
harness.service.validateAndSave({
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
input: newPasswordHost
|
||||
})
|
||||
).rejects.toThrow('SSH 认证失败')
|
||||
await expect(harness.service.getSnapshot()).resolves.toMatchObject({
|
||||
hosts: []
|
||||
})
|
||||
|
||||
const result = await harness.service.validateAndSave({
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
input: {
|
||||
...newPasswordHost,
|
||||
password: {
|
||||
action: 'replace',
|
||||
value: 'corrected password'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
host: {
|
||||
name: 'Build host',
|
||||
hostKey: {
|
||||
state: 'verified',
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
generation: 1
|
||||
}
|
||||
},
|
||||
connection: {
|
||||
connected: true,
|
||||
platform: 'linux'
|
||||
}
|
||||
})
|
||||
expect(result.connection.hostId).toBe(result.host.id)
|
||||
expect(harness.testConnection).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
password: 'corrected password',
|
||||
hostKey: keyA
|
||||
})
|
||||
)
|
||||
expect(JSON.stringify(await harness.service.getSnapshot())).not.toContain(
|
||||
'corrected password'
|
||||
)
|
||||
})
|
||||
|
||||
it('binds candidates to the inspected target and discards them on request', async () => {
|
||||
const harness = await createHarness()
|
||||
const inspection = await harness.service.inspectDraftHostKey({
|
||||
hostname: newPasswordHost.hostname,
|
||||
port: newPasswordHost.port,
|
||||
username: newPasswordHost.username
|
||||
})
|
||||
|
||||
await expect(
|
||||
harness.service.validateAndSave({
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
input: {
|
||||
...newPasswordHost,
|
||||
hostname: 'other.example.com'
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('配置已变化')
|
||||
expect(harness.testConnection).not.toHaveBeenCalled()
|
||||
|
||||
harness.service.discardCandidate(inspection.candidateId)
|
||||
await expect(
|
||||
harness.service.validateAndSave({
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
input: newPasswordHost
|
||||
})
|
||||
).rejects.toThrow('重新检查')
|
||||
})
|
||||
|
||||
it('supersedes older candidates for the same draft target', async () => {
|
||||
const harness = await createHarness()
|
||||
const first = await harness.service.inspectDraftHostKey({
|
||||
hostname: newPasswordHost.hostname,
|
||||
port: newPasswordHost.port,
|
||||
username: newPasswordHost.username
|
||||
})
|
||||
const second = await harness.service.inspectDraftHostKey({
|
||||
hostname: newPasswordHost.hostname,
|
||||
port: newPasswordHost.port,
|
||||
username: newPasswordHost.username
|
||||
})
|
||||
|
||||
await expect(
|
||||
harness.service.validateAndSave({
|
||||
candidateId: first.candidateId,
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
input: newPasswordHost
|
||||
})
|
||||
).rejects.toThrow('重新检查')
|
||||
await expect(
|
||||
harness.service.validateAndSave({
|
||||
candidateId: second.candidateId,
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
input: newPasswordHost
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
host: { name: 'Build host' }
|
||||
})
|
||||
expect(harness.testConnection).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects duplicate in-flight inspections for the same target', async () => {
|
||||
const harness = await createHarness()
|
||||
let finishInspection:
|
||||
| ((candidate: SshHostKeyCandidate) => void)
|
||||
| undefined
|
||||
harness.inspectHostKey.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishInspection = resolve
|
||||
})
|
||||
)
|
||||
const request = {
|
||||
hostname: newPasswordHost.hostname,
|
||||
port: newPasswordHost.port,
|
||||
username: newPasswordHost.username
|
||||
}
|
||||
const first = harness.service.inspectDraftHostKey(request)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.inspectHostKey).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.service.inspectDraftHostKey(request)
|
||||
).rejects.toThrow('正在检查')
|
||||
finishInspection?.(keyA)
|
||||
await expect(first).resolves.toMatchObject({
|
||||
fingerprintSha256: keyA.fingerprintSha256
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies changed keys and atomically replaces a validated edit', async () => {
|
||||
const harness = await createHarness()
|
||||
const host = await harness.store.commitValidated({
|
||||
input: {
|
||||
name: 'Build host',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'password',
|
||||
password: {
|
||||
action: 'replace',
|
||||
value: 'private password'
|
||||
}
|
||||
},
|
||||
hostKey: keyA
|
||||
})
|
||||
const decryptionsBeforeInspection = decrypt.mock.calls.length
|
||||
harness.inspectHostKey.mockResolvedValueOnce(keyB)
|
||||
|
||||
const inspection = await harness.service.inspectDraftHostKey({
|
||||
hostId: host.id,
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder'
|
||||
})
|
||||
|
||||
expect(inspection).toMatchObject({
|
||||
hostId: host.id,
|
||||
state: 'changed',
|
||||
fingerprintSha256: keyB.fingerprintSha256,
|
||||
previousHostKey: {
|
||||
algorithm: keyA.algorithm,
|
||||
fingerprintSha256: keyA.fingerprintSha256
|
||||
}
|
||||
})
|
||||
expect(decrypt).toHaveBeenCalledTimes(decryptionsBeforeInspection)
|
||||
const resolveConnectionTarget = vi.spyOn(
|
||||
harness.store,
|
||||
'resolveConnectionTarget'
|
||||
)
|
||||
const result = await harness.service.validateAndSave({
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyB.fingerprintSha256,
|
||||
input: {
|
||||
...newPasswordHost,
|
||||
name: 'Renamed host',
|
||||
password: { action: 'keep' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.host).toMatchObject({
|
||||
id: host.id,
|
||||
name: 'Renamed host',
|
||||
hostKey: {
|
||||
state: 'verified',
|
||||
fingerprintSha256: keyB.fingerprintSha256,
|
||||
generation: 2
|
||||
}
|
||||
})
|
||||
expect(harness.testConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: host.id,
|
||||
password: 'private password',
|
||||
hostKey: keyB
|
||||
})
|
||||
)
|
||||
expect(resolveConnectionTarget).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'hostname',
|
||||
{
|
||||
hostname: 'moved.example.com'
|
||||
}
|
||||
],
|
||||
[
|
||||
'port',
|
||||
{
|
||||
port: 2222
|
||||
}
|
||||
],
|
||||
[
|
||||
'username',
|
||||
{
|
||||
username: 'deployer'
|
||||
}
|
||||
],
|
||||
[
|
||||
'authentication',
|
||||
{
|
||||
authentication: 'system-agent' as const
|
||||
}
|
||||
]
|
||||
])(
|
||||
'rejects credential keep before resolution when %s changes',
|
||||
async (_field, change) => {
|
||||
const harness = await createHarness()
|
||||
const host = await harness.store.commitValidated({
|
||||
input: newPasswordHost,
|
||||
hostKey: keyA
|
||||
})
|
||||
const draft = {
|
||||
hostname: newPasswordHost.hostname,
|
||||
port: newPasswordHost.port,
|
||||
username: newPasswordHost.username,
|
||||
...change
|
||||
}
|
||||
const inspection =
|
||||
await harness.service.inspectDraftHostKey({
|
||||
hostId: host.id,
|
||||
hostname: draft.hostname,
|
||||
port: draft.port,
|
||||
username: draft.username
|
||||
})
|
||||
const resolveConnectionTarget = vi.spyOn(
|
||||
harness.store,
|
||||
'resolveConnectionTarget'
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.service.validateAndSave({
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
input: {
|
||||
...newPasswordHost,
|
||||
...change,
|
||||
password: { action: 'keep' }
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'地址、端口、用户名或认证方式已变化'
|
||||
)
|
||||
expect(resolveConnectionTarget).toHaveBeenCalledOnce()
|
||||
expect(harness.testConnection).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('allows credential keep when target and authentication are unchanged', async () => {
|
||||
const harness = await createHarness()
|
||||
const host = await harness.store.commitValidated({
|
||||
input: newPasswordHost,
|
||||
hostKey: keyA
|
||||
})
|
||||
const inspection = await harness.service.inspectDraftHostKey({
|
||||
hostId: host.id,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username
|
||||
})
|
||||
const resolveConnectionTarget = vi.spyOn(
|
||||
harness.store,
|
||||
'resolveConnectionTarget'
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.service.validateAndSave({
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
input: {
|
||||
...newPasswordHost,
|
||||
name: 'Renamed host',
|
||||
password: { action: 'keep' }
|
||||
}
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
host: {
|
||||
id: host.id,
|
||||
name: 'Renamed host'
|
||||
}
|
||||
})
|
||||
expect(resolveConnectionTarget).toHaveBeenCalledOnce()
|
||||
expect(harness.testConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
authentication: 'password',
|
||||
password: 'private password'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an edit when the durable host changes after inspection', async () => {
|
||||
const harness = await createHarness()
|
||||
const host = await harness.store.commitValidated({
|
||||
input: {
|
||||
name: 'Build host',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'system-agent',
|
||||
password: { action: 'clear' }
|
||||
},
|
||||
hostKey: keyA
|
||||
})
|
||||
const inspection = await harness.service.inspectDraftHostKey({
|
||||
hostId: host.id,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username
|
||||
})
|
||||
const identity = await harness.store.getHostIdentity(host.id)
|
||||
await harness.store.commitValidated({
|
||||
hostId: host.id,
|
||||
expectedRevision: identity.revision,
|
||||
input: {
|
||||
name: 'Changed elsewhere',
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
authentication: 'system-agent',
|
||||
password: { action: 'clear' }
|
||||
},
|
||||
hostKey: keyA
|
||||
})
|
||||
|
||||
await expect(
|
||||
harness.service.validateAndSave({
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
input: {
|
||||
name: 'Stale edit',
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
authentication: 'system-agent',
|
||||
password: { action: 'clear' }
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('配置已变化')
|
||||
expect(harness.testConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates target edits against the draft before replacing the durable host', async () => {
|
||||
const harness = await createHarness()
|
||||
const host = await harness.store.commitValidated({
|
||||
input: {
|
||||
name: 'Build host',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'password',
|
||||
password: {
|
||||
action: 'replace',
|
||||
value: 'old password'
|
||||
}
|
||||
},
|
||||
hostKey: keyA
|
||||
})
|
||||
harness.inspectHostKey.mockResolvedValueOnce(keyB)
|
||||
const inspection = await harness.service.inspectDraftHostKey({
|
||||
hostId: host.id,
|
||||
hostname: 'moved.example.com',
|
||||
port: 2222,
|
||||
username: 'deployer'
|
||||
})
|
||||
|
||||
expect(inspection).toMatchObject({
|
||||
state: 'unverified',
|
||||
fingerprintSha256: keyB.fingerprintSha256
|
||||
})
|
||||
const result = await harness.service.validateAndSave({
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyB.fingerprintSha256,
|
||||
input: {
|
||||
name: 'Moved host',
|
||||
hostname: 'moved.example.com',
|
||||
port: 2222,
|
||||
username: 'deployer',
|
||||
authentication: 'password',
|
||||
password: {
|
||||
action: 'replace',
|
||||
value: 'new password'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.host).toMatchObject({
|
||||
id: host.id,
|
||||
hostname: 'moved.example.com',
|
||||
port: 2222,
|
||||
username: 'deployer',
|
||||
hostKey: {
|
||||
fingerprintSha256: keyB.fingerprintSha256,
|
||||
generation: 2
|
||||
}
|
||||
})
|
||||
expect(harness.testConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostname: 'moved.example.com',
|
||||
port: 2222,
|
||||
username: 'deployer',
|
||||
password: 'new password'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('fires credential-free lifecycle hooks only after successful edit and removal commits', async () => {
|
||||
const onHostEdited = vi.fn()
|
||||
const onHostRemoved = vi.fn()
|
||||
const harness = await createHarness(1_000, {
|
||||
onHostEdited,
|
||||
onHostRemoved
|
||||
})
|
||||
const host = await harness.store.commitValidated({
|
||||
input: newPasswordHost,
|
||||
hostKey: keyA
|
||||
})
|
||||
const inspection = await harness.service.inspectDraftHostKey({
|
||||
hostId: host.id,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username
|
||||
})
|
||||
harness.testConnection.mockRejectedValueOnce(
|
||||
new Error('SSH 认证失败')
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.service.validateAndSave({
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
input: {
|
||||
...newPasswordHost,
|
||||
name: 'Renamed host',
|
||||
password: { action: 'keep' }
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('SSH 认证失败')
|
||||
expect(onHostEdited).not.toHaveBeenCalled()
|
||||
expect(onHostRemoved).not.toHaveBeenCalled()
|
||||
|
||||
await harness.service.validateAndSave({
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
input: {
|
||||
...newPasswordHost,
|
||||
name: 'Renamed host',
|
||||
password: { action: 'keep' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(onHostEdited).toHaveBeenCalledWith(host.id)
|
||||
expect(onHostEdited.mock.calls[0]).toEqual([host.id])
|
||||
expect(onHostRemoved).not.toHaveBeenCalled()
|
||||
|
||||
await harness.service.remove(host.id)
|
||||
|
||||
expect(onHostRemoved).toHaveBeenCalledWith(host.id)
|
||||
expect(onHostRemoved.mock.calls[0]).toEqual([host.id])
|
||||
await expect(
|
||||
harness.service.remove(host.id)
|
||||
).rejects.toThrow('不存在')
|
||||
expect(onHostRemoved).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('bounds the number of pending host-key candidates', async () => {
|
||||
const harness = await createHarness()
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
await harness.service.inspectDraftHostKey({
|
||||
hostname: `build-${index}.example.com`,
|
||||
port: 22,
|
||||
username: 'builder'
|
||||
})
|
||||
}
|
||||
|
||||
await expect(
|
||||
harness.service.inspectDraftHostKey({
|
||||
hostname: 'one-too-many.example.com',
|
||||
port: 22,
|
||||
username: 'builder'
|
||||
})
|
||||
).rejects.toThrow('检查过多')
|
||||
expect(harness.inspectHostKey).toHaveBeenCalledTimes(100)
|
||||
})
|
||||
|
||||
it('allows only one validation to consume a candidate', async () => {
|
||||
const harness = await createHarness()
|
||||
const inspection = await harness.service.inspectDraftHostKey({
|
||||
hostname: newPasswordHost.hostname,
|
||||
port: newPasswordHost.port,
|
||||
username: newPasswordHost.username
|
||||
})
|
||||
let finishConnection:
|
||||
| ((result: {
|
||||
connected: true
|
||||
latencyMs: number
|
||||
platform: 'linux'
|
||||
architecture: 'x64'
|
||||
shell: string
|
||||
homeDirectory: string
|
||||
detail: string
|
||||
}) => void)
|
||||
| undefined
|
||||
harness.testConnection.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishConnection = resolve
|
||||
})
|
||||
)
|
||||
const request = {
|
||||
candidateId: inspection.candidateId,
|
||||
fingerprintSha256: keyA.fingerprintSha256,
|
||||
input: newPasswordHost
|
||||
}
|
||||
const first = harness.service.validateAndSave(request)
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.testConnection).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.service.validateAndSave(request)
|
||||
).rejects.toThrow('正在验证')
|
||||
finishConnection?.({
|
||||
connected: true,
|
||||
latencyMs: 12,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
shell: '/bin/bash',
|
||||
homeDirectory: '/home/builder',
|
||||
detail: 'SSH 已连接,远端系统为 linux/x64'
|
||||
})
|
||||
await expect(first).resolves.toMatchObject({
|
||||
host: { name: 'Build host' }
|
||||
})
|
||||
await expect(harness.service.getSnapshot()).resolves.toMatchObject({
|
||||
hosts: [{ name: 'Build host' }]
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user