Extract multiplexer dialect and resumable terminal engine registries while preserving session and checkpoint compatibility. Share local-network and theme policies, add regression coverage, and update discovery actions to Cancel and Rescan with localized Telnet copy.
256 lines
11 KiB
Swift
256 lines
11 KiB
Swift
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import Testing
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
@Suite @MainActor
|
|
struct LocalNetworkActionTests {
|
|
enum IdentitySelection: CaseIterable { case unsaved, hostCustom, reusable }
|
|
|
|
@Test(arguments: IdentitySelection.allCases)
|
|
func macLookupUsesOnlyTheSelectedEditorAuthentication(_ selection: IdentitySelection) async throws {
|
|
let secrets = InMemorySecretStore()
|
|
var host = Host(
|
|
name: "Server", host: "draft.invalid",
|
|
proxy: .init(hostname: "proxy.invalid", username: "proxy-user"))
|
|
host.wakeOnLAN = .init(macAddress: "02:11:22:33:44:55", automaticallyWake: true)
|
|
let custom = selection == .hostCustom
|
|
let identity = Identity(
|
|
name: "Login", username: "saved-user", credentialRef: "saved-ssh",
|
|
ownerHostID: custom ? host.id : nil, customPurpose: custom ? .ssh : nil)
|
|
let proxyIdentity = Identity(
|
|
name: "Proxy", username: "proxy-user", credentialRef: "saved-proxy",
|
|
ownerHostID: custom ? host.id : nil, customPurpose: custom ? .proxy : nil)
|
|
let key = SSHKeyRef(label: "Draft key", credentialRef: "draft-key")
|
|
try secrets.setString("saved-password", for: identity.credentialRef, storage: identity.passwordStorage)
|
|
try secrets.setString(
|
|
"saved-proxy-password", for: proxyIdentity.credentialRef, storage: proxyIdentity.passwordStorage)
|
|
try secrets.setKeyCredential(
|
|
.software(.init(privateKey: "draft-private-key")), for: key.credentialRef, storage: key.storage)
|
|
if selection != .unsaved {
|
|
host.identityID = identity.id
|
|
host.proxy?.identityID = proxyIdentity.id
|
|
} else {
|
|
host.proxy = nil
|
|
}
|
|
let library = HostLibraryModel(
|
|
hosts: selection == .unsaved ? [] : [host], keys: [key],
|
|
identities: selection == .unsaved ? [] : [identity, proxyIdentity], secrets: secrets)
|
|
let originalHosts = library.hosts
|
|
let originalIdentities = library.identities
|
|
let sender = ManualWakeSender()
|
|
library.wakeOnLAN = WakeOnLANController(sender: sender, hasAccess: { true })
|
|
var draft = HostCustomIdentityDraft(host: host, library: library)
|
|
draft.sshUsername = "draft-user"
|
|
draft.sshPassword = "draft-password"
|
|
draft.sshKeyID = key.id
|
|
draft.usesCustomProxy = selection != .reusable
|
|
draft.proxyPassword = "draft-proxy-password"
|
|
let usesDraft = selection != .reusable
|
|
let resolver = MACLookupRecorder(
|
|
password: usesDraft ? "draft-password" : "saved-password",
|
|
proxyPassword: host.proxy == nil ? nil : (usesDraft ? "draft-proxy-password" : "saved-proxy-password"))
|
|
let model = HostMACAddressModel(
|
|
host: host, library: library, resolver: resolver, custom: draft, tailscaleUsername: "ignored")
|
|
defer { model.cancel() }
|
|
model.start()
|
|
try await finish(model)
|
|
#expect(await resolver.username == (usesDraft ? "draft-user" : "saved-user"))
|
|
#expect(await resolver.hasDraftKey == usesDraft)
|
|
#expect(await resolver.proxyUsername == (host.proxy == nil ? nil : "proxy-user"))
|
|
#expect(await resolver.receivedHost == "draft.invalid")
|
|
#expect(await resolver.hasWakeAction == false)
|
|
#expect(await resolver.secretsAreCleared)
|
|
#expect(sender.sent.isEmpty)
|
|
#expect(library.hosts == originalHosts)
|
|
#expect(library.identities == originalIdentities)
|
|
#expect(try secrets.string(for: identity.credentialRef, storage: identity.passwordStorage) == "saved-password")
|
|
#expect(
|
|
try secrets.string(for: proxyIdentity.credentialRef, storage: proxyIdentity.passwordStorage)
|
|
== "saved-proxy-password")
|
|
}
|
|
|
|
@Test func macLookupUsesTailscaleUsernameWithoutEditorCredentialsOrWrites() async throws {
|
|
var group = HostGroup(name: "Tailnet")
|
|
group.tailscale = .init(
|
|
network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net"))
|
|
var host = Host(name: "Managed", host: "managed.example.ts.net", groupID: group.id)
|
|
host.tailscale = .init(groupID: group.id, stableNodeID: "node")
|
|
let library = HostLibraryModel(groups: [group])
|
|
let provider = MACLookupTailscaleProvider()
|
|
library.tailscaleConnections = provider
|
|
var draft = HostCustomIdentityDraft()
|
|
draft.sshUsername = "ignored"
|
|
draft.sshPassword = "ignored"
|
|
draft.sshKeyID = UUID()
|
|
let resolver = MACLookupRecorder()
|
|
let model = HostMACAddressModel(
|
|
host: host, library: library, resolver: resolver, custom: draft, tailscaleUsername: "operator")
|
|
defer { model.cancel() }
|
|
model.start()
|
|
try await finish(model)
|
|
#expect(await resolver.username == "operator")
|
|
#expect(await resolver.credentialCount == 0)
|
|
#expect(await resolver.hasNetwork)
|
|
#expect(library.hosts.isEmpty && library.identities.isEmpty)
|
|
#expect(library.groups == [group])
|
|
}
|
|
|
|
@Test func manualWakeRequestsAccessBeforeConfigurationAndDoesNotReplay() {
|
|
let library = HostLibraryModel()
|
|
let sender = ManualWakeSender()
|
|
let access = ManualWakeAccess()
|
|
library.wakeOnLAN = WakeOnLANController(sender: sender, hasAccess: { access.allowed })
|
|
let missing = Host(name: "Missing", host: "missing.invalid")
|
|
var configured = missing
|
|
configured.wakeOnLAN.macAddress = "02:11:22:33:44:55"
|
|
var requests = 0
|
|
for host in [missing, configured] {
|
|
#expect(
|
|
library.requestWake(host) {
|
|
requests += 1
|
|
return access.allowed
|
|
} == .accessDenied)
|
|
}
|
|
#expect(requests == 2)
|
|
access.allowed = true
|
|
#expect(sender.sent.isEmpty)
|
|
#expect(library.requestWake(missing) { true } == .configurationRequired)
|
|
#expect(sender.sent.isEmpty)
|
|
#expect(library.requestWake(configured) { true } == .sent)
|
|
#expect(sender.sent.map(\.rawValue) == ["02:11:22:33:44:55"])
|
|
}
|
|
|
|
@Test func manualWakeReadsSavedPreferencesAfterAccessInsteadOfTheStaleCaller() {
|
|
var host = Host(name: "Host", host: "host.invalid")
|
|
host.wakeOnLAN.macAddress = "02:11:22:33:44:55"
|
|
let library = HostLibraryModel(hosts: [host])
|
|
let sender = ManualWakeSender()
|
|
library.wakeOnLAN = WakeOnLANController(sender: sender, hasAccess: { true })
|
|
#expect(
|
|
library.requestWake(host) {
|
|
var updated = host
|
|
updated.wakeOnLAN.macAddress = "02:11:22:33:44:66"
|
|
library.upsert(updated)
|
|
return true
|
|
} == .sent)
|
|
#expect(sender.sent.map(\.rawValue) == ["02:11:22:33:44:66"])
|
|
var missing = host
|
|
missing.wakeOnLAN = .init()
|
|
library.upsert(missing)
|
|
#expect(library.requestWake(host) { true } == .configurationRequired)
|
|
#expect(sender.sent.count == 1)
|
|
}
|
|
|
|
@Test func manualWakeReportsMissingServiceRevokedAccessAndSendFailure() {
|
|
var host = Host(name: "Host", host: "host.invalid")
|
|
host.wakeOnLAN.macAddress = "02:11:22:33:44:55"
|
|
let library = HostLibraryModel()
|
|
#expect(library.requestWake(host) { true } == .failed)
|
|
let sender = ManualWakeSender()
|
|
let access = ManualWakeAccess()
|
|
library.wakeOnLAN = WakeOnLANController(sender: sender, hasAccess: { access.allowed })
|
|
#expect(library.requestWake(host) { true } == .failed)
|
|
#expect(sender.sent.isEmpty)
|
|
access.allowed = true
|
|
sender.fails = true
|
|
#expect(library.requestWake(host) { true } == .failed)
|
|
#expect(sender.sent.isEmpty)
|
|
sender.fails = false
|
|
#expect(library.requestWake(host) { true } == .sent)
|
|
#expect(sender.sent.count == 1)
|
|
}
|
|
|
|
private func finish(_ model: HostMACAddressModel) async throws {
|
|
for _ in 0..<200 where !model.isFinished {
|
|
try await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
try #require(model.isFinished)
|
|
}
|
|
}
|
|
|
|
private actor MACLookupRecorder: HostMACAddressResolving {
|
|
private let password: String?
|
|
private let proxyPassword: String?
|
|
private var secrets: [SSHSecret] = []
|
|
var username: String?
|
|
var proxyUsername: String?
|
|
var receivedHost: String?
|
|
var credentialCount = 0
|
|
var hasDraftKey = false
|
|
var hasWakeAction = false
|
|
var hasNetwork = false
|
|
var secretsAreCleared: Bool { secrets.allSatisfy(\.isConsumed) }
|
|
|
|
init(password: String? = nil, proxyPassword: String? = nil) {
|
|
self.password = password
|
|
self.proxyPassword = proxyPassword
|
|
}
|
|
|
|
func resolve(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws
|
|
-> [HostMACAddressCandidate]
|
|
{
|
|
let configuration = request.configuration
|
|
username = configuration.username
|
|
proxyUsername = configuration.proxy?.username
|
|
receivedHost = configuration.host
|
|
credentialCount = configuration.credentials.count
|
|
hasWakeAction = configuration.beforeConnect != nil
|
|
hasNetwork = configuration.network != nil
|
|
var receivedPassword = false
|
|
for credential in configuration.credentials {
|
|
switch credential {
|
|
case .password(let secret):
|
|
receivedPassword = true
|
|
#expect(password.map(secret.matches) == true)
|
|
secrets.append(secret)
|
|
case .privateKey(let secret, _, _, _):
|
|
hasDraftKey = secret.matches("draft-private-key")
|
|
secrets.append(secret)
|
|
default: Issue.record("Unexpected credential")
|
|
}
|
|
}
|
|
#expect(receivedPassword == (password != nil))
|
|
if let proxyPassword {
|
|
let secret = try #require(configuration.proxy?.password)
|
|
#expect(secret.matches(proxyPassword))
|
|
secrets.append(secret)
|
|
} else {
|
|
#expect(configuration.proxy == nil)
|
|
}
|
|
return [.init(interface: "en0", address: MACAddress("02:11:22:33:44:55")!)]
|
|
}
|
|
}
|
|
|
|
private nonisolated final class ManualWakeSender: WakeOnLANSending, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var addresses: [MACAddress] = []
|
|
private var shouldFail = false
|
|
var fails: Bool {
|
|
get { lock.withLock { shouldFail } }
|
|
set { lock.withLock { shouldFail = newValue } }
|
|
}
|
|
var sent: [MACAddress] { lock.withLock { addresses } }
|
|
func send(to address: MACAddress) throws {
|
|
try lock.withLock {
|
|
if shouldFail { throw WakeOnLANError.noLocalNetwork }
|
|
addresses.append(address)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor private final class ManualWakeAccess {
|
|
var allowed = false
|
|
}
|
|
|
|
@MainActor private final class MACLookupTailscaleProvider: TailscaleConnectionProviding {
|
|
func connection(for groupID: UUID) throws -> any SSHNetworkDialing { MACLookupDialer() }
|
|
}
|
|
|
|
private nonisolated struct MACLookupDialer: SSHNetworkDialing {
|
|
func open(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { throw CancellationError() }
|
|
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> { [] }
|
|
}
|