Files
Termind/FrameworkTests/LocalNetworkActionTests.swift

330 lines
15 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
import TermindRemoteInfrastructure
import Testing
import struct TermindDomain.Host
@Suite @MainActor
struct LocalNetworkActionTests {
enum IdentitySelection: CaseIterable { case unsaved, hostCustom, reusable }
@Test(arguments: [false, true])
func telnetConfigurationWakesOnlyItsTargetAndRejectsRetainedAccessAfterExpiry(sshEnabled: Bool) async throws {
var hosts = (0..<4).map {
Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0)))
}
hosts[0].sshEnabled = sshEnabled
hosts[0].telnetEnabled = true
hosts[0].jumpHostID = hosts[3].id
hosts[0].wakeOnLAN = .init(macAddress: "02:11:22:33:44:55", automaticallyWake: true)
hosts[3].telnetEnabled = true
hosts[3].wakeOnLAN = .init(macAddress: "02:11:22:33:44:66", automaticallyWake: true)
let library = HostLibraryModel(hosts: hosts)
let sender = ManualWakeSender()
library.wakeOnLAN = WakeOnLANController(sender: sender)
let retained = try library.telnetConnectionConfiguration(for: hosts[3])
library.hostAccess.entitlement = .free
let allowed = try library.telnetConnectionConfiguration(for: hosts[0])
try allowed.accessCheck?()
await allowed.beforeConnect?()
#expect(sender.sent.map(\.rawValue) == ["02:11:22:33:44:55"])
let connection = TelnetConnection(output: { _ in })
defer { connection.disconnect() }
await #expect(throws: HostAccessError.lockedHost(hosts[3].id)) {
try await connection.connect(using: retained)
}
await retained.beforeConnect?()
#expect(sender.sent.count == 1)
}
@Test(arguments: [false, true])
func pendingManualWakeUsesFreshPreferencesAndHonorsCancellation(cancelled: Bool) async throws {
var hosts = (0..<4).map {
Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0)))
}
hosts[3].wakeOnLAN.macAddress = "02:11:22:33:44:55"
let library = HostLibraryModel(hosts: hosts)
let sender = ManualWakeSender()
library.wakeOnLAN = WakeOnLANController(sender: sender)
library.hostAccess.entitlement = .loading
var continuation: CheckedContinuation<Void, Never>?
library.resolveInitialHostEntitlement = {
await withCheckedContinuation { continuation = $0 }
library.hostAccess.entitlement = .purchased(.purchased)
}
defer { library.resolveInitialHostEntitlement = nil }
let target = hosts[3]
let operation = Task { try await library.requestWake(target) }
for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) }
#expect(continuation != nil)
#expect(sender.sent.isEmpty)
var updated = target
updated.wakeOnLAN.macAddress = "02:11:22:33:44:66"
library.upsert(updated)
if cancelled { operation.cancel() }
continuation?.resume()
do {
let outcome = try await operation.value
#expect(!cancelled)
#expect(outcome == .sent)
} catch {
#expect(cancelled && error is CancellationError)
}
#expect(sender.sent.map(\.rawValue) == (cancelled ? [] : ["02:11:22:33:44:66"]))
}
@Test func deletingTheHostDuringEntitlementResolutionCancelsManualWake() async throws {
var hosts = (0..<4).map {
Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0)))
}
hosts[3].wakeOnLAN.macAddress = "02:11:22:33:44:55"
let target = hosts[3]
let library = HostLibraryModel(hosts: hosts)
let sender = ManualWakeSender()
library.wakeOnLAN = WakeOnLANController(sender: sender)
library.hostAccess.entitlement = .loading
library.resolveInitialHostEntitlement = {
library.projectHostDeletion(target.id)
library.hostAccess.entitlement = .purchased(.purchased)
}
defer { library.resolveInitialHostEntitlement = nil }
await #expect(throws: CancellationError.self) { try await library.requestWake(target) }
#expect(sender.sent.isEmpty)
}
@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)
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 lockedManualWakeNeverSendsOrRequestsConfiguration() async throws {
var hosts = (0..<4).map {
Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0)))
}
hosts[3].wakeOnLAN.macAddress = "02:11:22:33:44:55"
let library = HostLibraryModel(hosts: hosts)
library.hostAccess.entitlement = .free
let sender = ManualWakeSender()
library.wakeOnLAN = WakeOnLANController(sender: sender)
await #expect(throws: HostAccessError.lockedHost(hosts[3].id)) {
try await library.requestWake(hosts[3])
}
#expect(sender.sent.isEmpty)
library.hostAccess.entitlement = .purchased(.purchased)
let outcome = try await library.requestWake(hosts[3])
#expect(outcome == .sent)
#expect(sender.sent.count == 1)
}
@Test func manualWakeReadsLatestSavedPreferences() async throws {
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)
var updated = host
updated.wakeOnLAN.macAddress = "02:11:22:33:44:66"
library.upsert(updated)
let sent = try await library.requestWake(host)
#expect(sent == .sent)
#expect(sender.sent.map(\.rawValue) == ["02:11:22:33:44:66"])
updated.wakeOnLAN = .init()
library.upsert(updated)
let unconfigured = try await library.requestWake(host)
#expect(unconfigured == .configurationRequired)
#expect(sender.sent.count == 1)
}
@Test func manualWakeReportsMissingServiceAndSendFailure() async throws {
var host = Host(name: "Host", host: "host.invalid")
host.wakeOnLAN.macAddress = "02:11:22:33:44:55"
let library = HostLibraryModel()
let unavailable = try await library.requestWake(host)
#expect(unavailable == .failed)
let sender = ManualWakeSender()
library.wakeOnLAN = WakeOnLANController(sender: sender)
sender.fails = true
let failed = try await library.requestWake(host)
#expect(failed == .failed)
#expect(sender.sent.isEmpty)
sender.fails = false
let sent = try await library.requestWake(host)
#expect(sent == .sent)
}
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 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> { [] }
}