Distinguish SSH route admission from Telnet target admission and wire Telnet access and automatic Wake hooks. Await entitlement resolution for manual Wake and duplication before host or credential mutation. Use explicit protocol-extension forwarding overloads so existential single-argument calls cannot bypass the concrete admission witness. Remove dead paywall state and update obsolete quota/date test fixtures. Validation: 81 focused macOS and 58 focused iOS tests passed.
327 lines
15 KiB
Swift
327 lines
15 KiB
Swift
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> { [] }
|
|
}
|