Files
Termind/TFMTests/ConnectionJourneyTests.swift
T
2026-09-22 01:28:47 +08:00

369 lines
13 KiB
Swift

import Foundation
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindRemoteInfrastructure
import Testing
import struct TermindDomain.Host
@testable import Termind
@testable import TermindPresentation
@Suite
@MainActor
struct ConnectionJourneyTests {
@Test
func telnetUsesItsOwnSymbolThroughoutTheConnectionJourney() {
#expect(ConnectionPurpose.telnetSession.symbol(for: .header) == "text.rectangle.fill")
#expect(ConnectionPurpose.telnetSession.symbol(for: .routeDestination) == "text.rectangle.fill")
#expect(ConnectionPurpose.sshSession.symbol(for: .header) == "apple.terminal.fill")
#expect(ConnectionPurpose.sshSession.symbol(for: .routeDestination) == "apple.terminal.fill")
#expect(ConnectionPurpose.sftpChannel.symbol(for: .header) == "folder.fill.badge.gearshape")
#expect(ConnectionPurpose.sftpChannel.symbol(for: .routeDestination) == "folder.fill")
}
@Test
func routeStartsAtBlueLocalEndpointAndKeepsHostsPending() {
let jump = Host(name: "Bastion", host: "jump.example")
var destination = Host(name: "Production", host: "prod.example")
destination.jumpHostID = jump.id
let journey = ConnectionJourney()
journey.begin(
host: destination, route: [jump, destination],
purpose: .sshSession, username: "deploy"
)
#expect(journey.phase == .connecting)
#expect(journey.endpoints.count == 3)
#expect(journey.endpoints[0].role == .local)
#expect(journey.completedEndpointIndex == 0)
#expect(journey.activeEndpointIndex == 0)
#expect(journey.endpoints[1].hostID == jump.id)
#expect(journey.endpoints[2].hostID == destination.id)
}
@Test
func reconnectUsesTheSameJourneyInReconnectMode() {
let destination = Host(name: "Production", host: "prod.example")
let journey = ConnectionJourney()
journey.begin(
host: destination, route: [destination],
purpose: .sshSession, username: "deploy", mode: .reconnection
)
#expect(journey.mode == .reconnection)
#expect(journey.phase == .connecting)
#expect(journey.isPresented)
}
@Test
func authenticationCheckpointAppearsOnlyWhenRequested() {
let destination = Host(name: "Server", host: "server.example")
let journey = ConnectionJourney()
journey.begin(
host: destination, route: [destination],
purpose: .sftpChannel, username: nil
)
#expect(journey.checkpoint == nil)
journey.receive(.connecting(destination.id))
#expect(journey.checkpoint == nil)
journey.waitForAuthentication(at: destination.id)
#expect(journey.phase == .waiting)
#expect(journey.checkpoint?.kind == .authentication)
#expect(journey.checkpoint?.endpointIndex == 1)
journey.resume()
#expect(journey.phase == .connecting)
#expect(journey.checkpoint == nil)
#expect(!journey.waitsForLocalIdentity)
}
@Test
func missingLocalIdentityKeepsEntireRemoteRoutePending() {
let jump = Host(name: "Bastion", host: "jump.example")
let destination = Host(name: "Server", host: "server.example")
let journey = ConnectionJourney()
journey.begin(
host: destination, route: [jump, destination],
purpose: .sshSession, username: nil
)
journey.waitForLocalIdentity(at: jump.id)
#expect(journey.phase == .waiting)
#expect(journey.checkpoint == nil)
#expect(journey.waitsForLocalIdentity)
#expect(journey.completedEndpointIndex == 0)
#expect(journey.activeEndpointIndex == 0)
journey.resume()
#expect(journey.phase == .connecting)
#expect(!journey.waitsForLocalIdentity)
}
@Test
func localRequirementTransitionsBetweenIdentityAndPassphraseSteps() {
let destination = Host(name: "Server", host: "server.example")
let journey = ConnectionJourney()
journey.begin(
host: destination, route: [destination],
purpose: .sshSession, username: "deploy"
)
journey.waitForLocalIdentity(at: destination.id)
#expect(journey.waitsForLocalIdentity)
#expect(!journey.waitsForLocalPassphrase)
journey.waitForLocalPassphrase(at: destination.id)
#expect(journey.waitsForLocalPassphrase)
#expect(!journey.waitsForLocalIdentity)
journey.waitForLocalIdentity(at: destination.id)
#expect(journey.waitsForLocalIdentity)
#expect(!journey.waitsForLocalPassphrase)
journey.resume()
#expect(journey.localRequirement == nil)
}
@Test
func authenticationPromptDistinguishesIdentityCollectionFromServerChallenge() {
let host = Host(name: "Server", host: "server.example")
let localPrompt = SSHAuthenticationPrompt(
host: host, username: "", selectedKeyID: nil,
failureMessage: nil, mode: .localIdentity
)
let serverPrompt = SSHAuthenticationPrompt(
host: host, username: "deploy", selectedKeyID: nil,
failureMessage: "Authentication rejected", mode: .serverChallenge
)
#expect(localPrompt.mode == .localIdentity)
#expect(serverPrompt.mode == .serverChallenge)
}
@Test
func routeProgressIsMonotonicAcrossReconnectProbes() {
let first = Host(name: "Jump", host: "jump.example")
let destination = Host(name: "Server", host: "server.example")
let journey = ConnectionJourney()
journey.begin(
host: destination, route: [first, destination],
purpose: .sshSession, username: "root"
)
journey.receive(.connecting(first.id))
journey.receive(.connected(first.id))
journey.receive(.connecting(destination.id))
#expect(journey.completedEndpointIndex == 1)
#expect(journey.activeEndpointIndex == 2)
journey.receive(.connecting(first.id))
#expect(journey.completedEndpointIndex == 1)
#expect(journey.activeEndpointIndex == 2)
}
@Test
func failureKeepsFutureRoutePendingAndRecordsSanitizedLog() {
let first = Host(name: "Jump", host: "jump.example")
let destination = Host(name: "Server", host: "server.example")
let journey = ConnectionJourney()
journey.begin(
host: destination, route: [first, destination],
purpose: .sshSession, username: "deploy"
)
journey.receive(.connected(first.id))
journey.fail("Connection refused", at: destination.id)
#expect(journey.phase == .failed("Connection refused"))
#expect(journey.completedEndpointIndex == 1)
#expect(journey.activeEndpointIndex == 2)
#expect(journey.plainTextLog.contains("Connection refused"))
#expect(!journey.plainTextLog.contains("password"))
}
@Test
func temporaryAuthenticationIsScopedToItsJumpHost() throws {
let jump = Host(name: "Bastion", host: "jump.example")
var destination = Host(name: "Server", host: "server.example")
destination.jumpHostID = jump.id
let store = HostLibraryModel(
hosts: [jump, destination],
knownHosts: [
KnownHostEntry(
hostname: jump.host, fingerprint: "SHA256:jump"
)
]
)
let submission = SSHAuthenticationSubmission(
username: "bastion-user", password: "one-use-secret",
passphrase: nil, keyID: nil
)
let configurations = try store.sshJumpConfigurations(
for: destination, submissions: [jump.id: submission]
)
#expect(configurations.count == 1)
#expect(configurations[0].id == jump.id)
#expect(configurations[0].username == "bastion-user")
#expect(store.host(destination.id)?.identityID == nil)
}
@Test
func temporaryHostKeyApprovalDoesNotPersist() throws {
let host = Host(name: "Server", host: "server.example")
let store = HostLibraryModel(hosts: [host])
let temporary = SSHHostKeyIdentity(
algorithm: .ed25519, fingerprint: "SHA256:temporary"
)
let keys = try store.expectedHostKeys(
for: host, temporaryHostKeys: [host.id: temporary]
)
#expect(keys == [temporary])
#expect(store.knownHosts(for: host).isEmpty)
}
@Test
func temporaryApprovalIsAddedToSavedHostKeys() throws {
let host = Host(name: "Server", host: "server.example")
let saved = SSHHostKeyIdentity(
algorithm: .ed25519, fingerprint: "SHA256:saved"
)
let temporary = SSHHostKeyIdentity(
algorithm: .ed25519, fingerprint: "SHA256:temporary"
)
let store = HostLibraryModel(
hosts: [host],
knownHosts: [
KnownHostEntry(
hostname: host.host, port: host.port,
algorithm: saved.algorithm, fingerprint: saved.fingerprint
)
]
)
let keys = try store.expectedHostKeys(
for: host, temporaryHostKeys: [host.id: temporary]
)
#expect(keys == [saved, temporary])
}
@Test
func hostKeepsMultipleKeysOfTheSameType() throws {
let host = Host(name: "Server", host: "server.example")
let first = KnownHostEntry(
hostname: host.host, algorithm: .ed25519,
fingerprint: "SHA256:first", addedAt: Date(timeIntervalSince1970: 1)
)
let second = KnownHostEntry(
hostname: host.host, algorithm: .ed25519,
fingerprint: "SHA256:second", addedAt: Date(timeIntervalSince1970: 2)
)
let store = HostLibraryModel(hosts: [host], knownHosts: [first])
store.trust(second)
#expect(store.knownHosts(for: host).map(\.fingerprint) == ["SHA256:second", "SHA256:first"])
#expect(try store.expectedHostKeys(for: host) == [first.identity, second.identity])
}
@Test
func trustingTheSameFingerprintIsIdempotent() {
let host = Host(name: "Server", host: "server.example")
let original = KnownHostEntry(
hostname: host.host, algorithm: .ed25519,
fingerprint: "SHA256:key", addedAt: Date(timeIntervalSince1970: 1)
)
let store = HostLibraryModel(hosts: [host], knownHosts: [original])
store.trust(
KnownHostEntry(
hostname: host.host, algorithm: .ed25519,
fingerprint: original.fingerprint, addedAt: Date(timeIntervalSince1970: 2)
)
)
let entries = store.knownHosts(for: host)
#expect(entries.count == 1)
#expect(entries[0].id == original.id)
#expect(entries[0].addedAt == Date(timeIntervalSince1970: 2))
}
@Test
func trustAndForgetOperationsApplyToTheHostKeySet() {
let host = Host(name: "Server", host: "server.example")
let first = KnownHostEntry(hostname: host.host, fingerprint: "SHA256:first")
let second = KnownHostEntry(hostname: host.host, fingerprint: "SHA256:second")
let store = HostLibraryModel(hosts: [host], knownHosts: [first, second])
store.trust(
KnownHostEntry(hostname: host.host, fingerprint: "SHA256:third")
)
#expect(store.knownHosts(for: host).count == 3)
store.forget(knownHost: first.id)
#expect(store.knownHosts(for: host).count == 2)
store.forgetKnownHosts(for: host)
#expect(store.knownHosts(for: host).isEmpty)
}
@Test
func hostKeyValidationAcceptsAnyTrustedIdentityAndRejectsUnknownKeys() throws {
let first = SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: "SHA256:first")
let second = SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: "SHA256:second")
let expected: Set<SSHHostKeyIdentity> = [first, second]
try SSHConnectionOpener.validateHostKey(
SSHHostKeyObservation(
hostname: "server.example", port: 22,
algorithm: second.algorithm, fingerprint: second.fingerprint
),
expected: expected
)
#expect(throws: TermindSSHError.self) {
try SSHConnectionOpener.validateHostKey(
SSHHostKeyObservation(
hostname: "server.example", port: 22,
algorithm: .ed25519, fingerprint: "SHA256:unknown"
),
expected: expected
)
}
#expect(throws: TermindSSHError.self) {
try SSHConnectionOpener.validateHostKey(
SSHHostKeyObservation(
hostname: "server.example", port: 22,
algorithm: .ecdsa256, fingerprint: first.fingerprint
),
expected: expected
)
}
}
@Test
func hostEditorCommitDoesNotMoveSharedKnownHosts() {
var host = Host(name: "Server", host: "old.example")
let shared = Host(name: "Other Login", host: "old.example")
let entry = KnownHostEntry(hostname: host.host, fingerprint: "SHA256:old")
let store = HostLibraryModel(hosts: [host, shared], knownHosts: [entry])
host.host = "new.example"
host.port = 2222
#expect(store.saveHost(host))
#expect(store.knownHosts == [entry])
#expect(store.knownHosts(for: host).isEmpty)
#expect(store.knownHosts(for: shared) == [entry])
}
}