330 lines
15 KiB
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> { [] }
|
|
}
|