fix(pro): enforce protocol-aware host admission before mutation
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.
This commit is contained in:
1 parent
f0b71db566
commit
9a36fde226
25 files changed
+819
-167
No files matched your search
@@ -17,6 +17,165 @@ struct HostAccessTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [EntitlementStatus.free, .loading, .verificationFailed])
|
||||
func existentialSSHAdmissionUsesThePolicyForBothTargetsAndJumps(status: EntitlementStatus) throws {
|
||||
var hosts = fixtures(4)
|
||||
hosts[0].telnetEnabled = true
|
||||
hosts[0].jumpHostID = hosts[3].id
|
||||
let library = HostLibraryModel(hosts: hosts)
|
||||
library.hostAccess.entitlement = status
|
||||
let store: any RemoteSessionHostStore = library
|
||||
let expected: HostAccessError =
|
||||
status == .free
|
||||
? .lockedHost(hosts[3].id)
|
||||
: (status == .loading ? .verifyingEntitlement : .entitlementVerificationFailed)
|
||||
for host in [hosts[0], hosts[3]] {
|
||||
#expect(throws: expected) { try store.requireHostAccess(host) }
|
||||
#expect(throws: expected) { try store.requireHostAccess(host, protocol: .ssh) }
|
||||
}
|
||||
try store.requireHostAccess(hosts[0], protocol: .telnet)
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true])
|
||||
func existentialSSHResolutionWaitsForTheConcreteEntitlementResolver(lockedJump: Bool) async throws {
|
||||
var hosts = fixtures(4)
|
||||
hosts[0].jumpHostID = hosts[3].id
|
||||
let library = HostLibraryModel(hosts: hosts)
|
||||
library.hostAccess.entitlement = .loading
|
||||
let store: any RemoteSessionHostStore = library
|
||||
var continuation: CheckedContinuation<Void, Never>?
|
||||
var resolutions = 0
|
||||
library.resolveInitialHostEntitlement = {
|
||||
resolutions += 1
|
||||
await withCheckedContinuation { continuation = $0 }
|
||||
library.hostAccess.entitlement = .purchased(.purchased)
|
||||
}
|
||||
defer { library.resolveInitialHostEntitlement = nil }
|
||||
let target = hosts[lockedJump ? 0 : 3]
|
||||
let operation = Task { try await store.resolveHostAccess(target) }
|
||||
for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) }
|
||||
#expect(continuation != nil)
|
||||
#expect(resolutions == 1)
|
||||
#expect(throws: HostAccessError.verifyingEntitlement) { try store.requireHostAccess(target) }
|
||||
continuation?.resume()
|
||||
try await operation.value
|
||||
try store.requireHostAccess(target)
|
||||
#expect(resolutions == 1)
|
||||
}
|
||||
|
||||
@Test(arguments: [EntitlementStatus.free, .loading], [false, true])
|
||||
func telnetAdmissionIgnoresTheIndependentSSHJump(status: EntitlementStatus, sshEnabled: Bool) async throws {
|
||||
var hosts = fixtures(4)
|
||||
hosts[0].sshEnabled = sshEnabled
|
||||
hosts[0].telnetEnabled = true
|
||||
hosts[0].jumpHostID = hosts[3].id
|
||||
let library = HostLibraryModel(hosts: hosts)
|
||||
library.hostAccess.entitlement = status
|
||||
var refreshes = 0
|
||||
library.resolveInitialHostEntitlement = { refreshes += 1 }
|
||||
defer { library.resolveInitialHostEntitlement = nil }
|
||||
|
||||
try await library.resolveHostAccess(hosts[0], protocol: .telnet)
|
||||
try library.require(hosts[0], for: .telnetTerminal)
|
||||
#expect(library.isEligible(hosts[0], for: .telnetTerminal))
|
||||
#expect(library.isEligible(hosts[0], for: .preferredTerminal) == !sshEnabled)
|
||||
#expect(library.hosts(matching: "", for: .telnetTerminal).map(\.id) == [hosts[0].id])
|
||||
#expect(refreshes == 0)
|
||||
for useCase in [HostUseCase.sshTerminal, .sftp, .metrics] {
|
||||
if sshEnabled {
|
||||
#expect(throws: HostAccessError.self) { try library.require(hosts[0], for: useCase) }
|
||||
} else {
|
||||
#expect(throws: HostProtocolUnavailableError(useCase: useCase)) {
|
||||
try library.require(hosts[0], for: useCase)
|
||||
}
|
||||
}
|
||||
}
|
||||
#expect(throws: HostAccessError.self) { try library.requireHostAccess(hosts[3], protocol: .telnet) }
|
||||
}
|
||||
|
||||
@Test(arguments: [EntitlementStatus.purchased(.purchased), .free, .verificationFailed])
|
||||
func duplicateWaitsForAdmissionWithoutWritingHostsOrCredentials(status: EntitlementStatus) async throws {
|
||||
var hosts = fixtures(3)
|
||||
let sourceID = hosts[0].id
|
||||
let custom = Identity(
|
||||
name: "Custom", username: "deploy", credentialRef: "source-secret",
|
||||
ownerHostID: sourceID, customPurpose: .ssh)
|
||||
hosts[0].identityID = custom.id
|
||||
let library = HostLibraryModel(hosts: hosts, identities: [custom])
|
||||
library.hostAccess.entitlement = .loading
|
||||
var upgrades = 0
|
||||
library.requestHostUpgrade = { upgrades += 1 }
|
||||
var continuation: CheckedContinuation<Void, Never>?
|
||||
library.resolveInitialHostEntitlement = {
|
||||
await withCheckedContinuation { continuation = $0 }
|
||||
library.hostAccess.entitlement = status
|
||||
}
|
||||
defer { library.resolveInitialHostEntitlement = nil }
|
||||
let original = hosts[0]
|
||||
let operation = Task { try await library.duplicateHost(original, name: "Copy") }
|
||||
for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) }
|
||||
#expect(continuation != nil)
|
||||
#expect(library.hosts == hosts)
|
||||
#expect(library.identities == [custom])
|
||||
continuation?.resume()
|
||||
do {
|
||||
let saved = try await operation.value
|
||||
#expect(saved)
|
||||
#expect(status.hasProAccess)
|
||||
let copy = try #require(library.hosts.first { !hosts.map(\.id).contains($0.id) })
|
||||
#expect(library.hosts.count == 4)
|
||||
#expect(copy.identityID == nil)
|
||||
#expect(copy.createdAt != nil && copy.createdAt != original.createdAt)
|
||||
#expect(library.host(sourceID) == original)
|
||||
} catch {
|
||||
#expect(!status.hasProAccess)
|
||||
#expect((error as? HostAccessError) == (status == .free ? .hostLimit : .entitlementVerificationFailed))
|
||||
#expect(library.hosts == hosts)
|
||||
}
|
||||
#expect(library.identities == [custom])
|
||||
#expect(upgrades == (status == .free ? 1 : 0))
|
||||
}
|
||||
|
||||
@Test func underLimitDuplicationDoesNotWaitForEntitlement() async throws {
|
||||
let host = fixtures(1)[0]
|
||||
let library = HostLibraryModel(hosts: [host])
|
||||
library.hostAccess.entitlement = .loading
|
||||
library.resolveInitialHostEntitlement = { Issue.record("An under-limit duplicate must not wait") }
|
||||
let saved = try await library.duplicateHost(host, name: "Copy")
|
||||
#expect(saved)
|
||||
#expect(library.hosts.count == 2)
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true])
|
||||
func duplicateDetachesOnlyHostOwnedIdentities(custom: Bool) async throws {
|
||||
var host = fixtures(1)[0]
|
||||
let purposes: [HostCustomIdentityPurpose] = [.ssh, .telnet, .proxy, .telnetProxy]
|
||||
let identities = purposes.map {
|
||||
Identity(
|
||||
name: $0.rawValue, username: "deploy",
|
||||
ownerHostID: custom ? host.id : nil, customPurpose: custom ? $0 : nil)
|
||||
}
|
||||
host.identityID = identities[0].id
|
||||
host.telnetEnabled = true
|
||||
host.telnetIdentityID = identities[1].id
|
||||
host.proxy = HostProxy(hostname: "proxy.invalid", username: "ssh-proxy", identityID: identities[2].id)
|
||||
host.telnetProxy = HostProxy(hostname: "proxy.invalid", username: "telnet-proxy", identityID: identities[3].id)
|
||||
let library = HostLibraryModel(hosts: [host], identities: identities)
|
||||
library.hostAccess.entitlement = .free
|
||||
let saved = try await library.duplicateHost(host, name: "Copy")
|
||||
#expect(saved)
|
||||
let copy = try #require(library.hosts.first { $0.id != host.id })
|
||||
#expect(copy.identityID == (custom ? nil : host.identityID))
|
||||
#expect(copy.telnetIdentityID == (custom ? nil : host.telnetIdentityID))
|
||||
#expect(copy.proxy?.identityID == (custom ? nil : host.proxy?.identityID))
|
||||
#expect(copy.telnetProxy?.identityID == (custom ? nil : host.telnetProxy?.identityID))
|
||||
#expect(copy.proxy?.username == (custom ? "" : "ssh-proxy"))
|
||||
#expect(copy.telnetProxy?.username == (custom ? "" : "telnet-proxy"))
|
||||
#expect(library.host(host.id) == host)
|
||||
#expect(library.identities.count == identities.count)
|
||||
for identity in identities { #expect(library.identity(identity.id) == identity) }
|
||||
}
|
||||
|
||||
@Test func initialEntitlementResolutionFinishesBeforeNewHostMutation() async throws {
|
||||
let library = HostLibraryModel(hosts: fixtures(3))
|
||||
library.hostAccess.entitlement = .loading
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindRemoteInfrastructure
|
||||
import Testing
|
||||
|
||||
import struct TermindDomain.Host
|
||||
@@ -9,6 +10,90 @@ import struct TermindDomain.Host
|
||||
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()
|
||||
@@ -98,7 +183,7 @@ struct LocalNetworkActionTests {
|
||||
#expect(library.groups == [group])
|
||||
}
|
||||
|
||||
@Test func lockedManualWakeNeverSendsOrRequestsConfiguration() {
|
||||
@Test func lockedManualWakeNeverSendsOrRequestsConfiguration() async throws {
|
||||
var hosts = (0..<4).map {
|
||||
Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0)))
|
||||
}
|
||||
@@ -107,14 +192,17 @@ struct LocalNetworkActionTests {
|
||||
library.hostAccess.entitlement = .free
|
||||
let sender = ManualWakeSender()
|
||||
library.wakeOnLAN = WakeOnLANController(sender: sender)
|
||||
#expect(library.requestWake(hosts[3]) == .accessDenied)
|
||||
await #expect(throws: HostAccessError.lockedHost(hosts[3].id)) {
|
||||
try await library.requestWake(hosts[3])
|
||||
}
|
||||
#expect(sender.sent.isEmpty)
|
||||
library.hostAccess.entitlement = .purchased(.purchased)
|
||||
#expect(library.requestWake(hosts[3]) == .sent)
|
||||
let outcome = try await library.requestWake(hosts[3])
|
||||
#expect(outcome == .sent)
|
||||
#expect(sender.sent.count == 1)
|
||||
}
|
||||
|
||||
@Test func manualWakeReadsLatestSavedPreferences() {
|
||||
@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])
|
||||
@@ -123,26 +211,31 @@ struct LocalNetworkActionTests {
|
||||
var updated = host
|
||||
updated.wakeOnLAN.macAddress = "02:11:22:33:44:66"
|
||||
library.upsert(updated)
|
||||
#expect(library.requestWake(host) == .sent)
|
||||
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)
|
||||
#expect(library.requestWake(host) == .configurationRequired)
|
||||
let unconfigured = try await library.requestWake(host)
|
||||
#expect(unconfigured == .configurationRequired)
|
||||
#expect(sender.sent.count == 1)
|
||||
}
|
||||
|
||||
@Test func manualWakeReportsMissingServiceAndSendFailure() {
|
||||
@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()
|
||||
#expect(library.requestWake(host) == .failed)
|
||||
let unavailable = try await library.requestWake(host)
|
||||
#expect(unavailable == .failed)
|
||||
let sender = ManualWakeSender()
|
||||
library.wakeOnLAN = WakeOnLANController(sender: sender)
|
||||
sender.fails = true
|
||||
#expect(library.requestWake(host) == .failed)
|
||||
let failed = try await library.requestWake(host)
|
||||
#expect(failed == .failed)
|
||||
#expect(sender.sent.isEmpty)
|
||||
sender.fails = false
|
||||
#expect(library.requestWake(host) == .sent)
|
||||
let sent = try await library.requestWake(host)
|
||||
#expect(sent == .sent)
|
||||
}
|
||||
|
||||
private func finish(_ model: HostMACAddressModel) async throws {
|
||||
@@ -223,10 +316,6 @@ private nonisolated final class ManualWakeSender: WakeOnLANSending, @unchecked S
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private final class ManualWakeAccess {
|
||||
var allowed = false
|
||||
}
|
||||
|
||||
@MainActor private final class MACLookupTailscaleProvider: TailscaleConnectionProviding {
|
||||
func connection(for groupID: UUID) throws -> any SSHNetworkDialing { MACLookupDialer() }
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import struct TermindDomain.Host
|
||||
@MainActor
|
||||
public protocol RemoteSessionHostStore: AnyObject, Sendable {
|
||||
func connectionAccessCheck(for hostIDs: [UUID]) -> HostOperationAccessCheck?
|
||||
func requireHostAccess(_ host: Host) throws
|
||||
func resolveHostAccess(_ host: Host) async throws
|
||||
func requireHostAccess(_ host: Host, protocol connectionProtocol: HostTerminalProtocol) throws
|
||||
func resolveHostAccess(_ host: Host, protocol connectionProtocol: HostTerminalProtocol) async throws
|
||||
func requireSavingHosts(_ proposed: [Host]) throws
|
||||
func connectionWakeAction(for hostIDs: [UUID]) -> ConnectionWakeAction?
|
||||
func host(_ id: Host.ID) -> Host?
|
||||
@@ -75,8 +75,15 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
|
||||
|
||||
extension RemoteSessionHostStore {
|
||||
public func connectionAccessCheck(for hostIDs: [UUID]) -> HostOperationAccessCheck? { nil }
|
||||
public func requireHostAccess(_ host: Host) throws {}
|
||||
public func resolveHostAccess(_ host: Host) async throws { try requireHostAccess(host) }
|
||||
// Explicit overloads preserve witness dispatch; default arguments on a fallback can bypass it.
|
||||
public func requireHostAccess(_ host: Host) throws { try requireHostAccess(host, protocol: .ssh) }
|
||||
public func requireHostAccess(_ host: Host, protocol connectionProtocol: HostTerminalProtocol) throws {}
|
||||
public func resolveHostAccess(_ host: Host) async throws { try await resolveHostAccess(host, protocol: .ssh) }
|
||||
public func resolveHostAccess(
|
||||
_ host: Host, protocol connectionProtocol: HostTerminalProtocol
|
||||
) async throws {
|
||||
try requireHostAccess(host, protocol: connectionProtocol)
|
||||
}
|
||||
public func resolveHostAccess(_ host: Host, route: SSHConnectionRoute?) async throws {
|
||||
try await resolveHostAccess(host)
|
||||
for node in route?.hosts ?? [] { try await resolveHostAccess(node) }
|
||||
@@ -133,7 +140,7 @@ extension RemoteSessionHostStore {
|
||||
}
|
||||
|
||||
public func telnetConnectionConfiguration(for host: Host) throws -> TelnetConnectionConfiguration {
|
||||
try requireHostAccess(host)
|
||||
try requireHostAccess(host, protocol: .telnet)
|
||||
guard host.telnetEnabled else { throw HostProtocolUnavailableError(useCase: .telnetTerminal) }
|
||||
let identity = identity(for: host, protocol: .telnet)
|
||||
let username = identity?.username.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -144,10 +151,13 @@ extension RemoteSessionHostStore {
|
||||
} else {
|
||||
password = nil
|
||||
}
|
||||
return TelnetConnectionConfiguration(
|
||||
var configuration = TelnetConnectionConfiguration(
|
||||
hostID: host.id, host: host.host, port: host.telnetPort,
|
||||
username: username?.isEmpty == false ? username : nil,
|
||||
password: password, charset: host.telnetCharset)
|
||||
configuration.accessCheck = connectionAccessCheck(for: [host.id])
|
||||
configuration.beforeConnect = connectionWakeAction(for: [host.id])
|
||||
return configuration
|
||||
}
|
||||
|
||||
/// Captures a desktop authentication form without saving its input. A
|
||||
|
||||
@@ -3,7 +3,6 @@ import TermindDomain
|
||||
import struct TermindDomain.Host
|
||||
|
||||
public nonisolated enum HostWakeOutcome: Equatable, Sendable {
|
||||
case accessDenied
|
||||
case configurationRequired
|
||||
case sent
|
||||
case failed
|
||||
@@ -11,8 +10,16 @@ public nonisolated enum HostWakeOutcome: Equatable, Sendable {
|
||||
|
||||
extension HostLibraryModel {
|
||||
/// Lock checks precede configuration and packet transmission.
|
||||
public func requestWake(_ proposed: Host) -> HostWakeOutcome {
|
||||
guard (try? hostAccess.requireAccess(to: proposed.id, in: hosts)) != nil else { return .accessDenied }
|
||||
public func requestWake(_ proposed: Host) async throws -> HostWakeOutcome {
|
||||
let wasSaved = host(proposed.id) != nil
|
||||
do {
|
||||
try hostAccess.requireAccess(to: proposed.id, in: hosts)
|
||||
} catch HostAccessError.verifyingEntitlement {
|
||||
await resolveInitialHostEntitlement?()
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
guard !wasSaved || host(proposed.id) != nil else { throw CancellationError() }
|
||||
try hostAccess.requireAccess(to: proposed.id, in: hosts)
|
||||
let host = host(proposed.id) ?? proposed
|
||||
guard host.wakeOnLAN.address != nil else { return .configurationRequired }
|
||||
do {
|
||||
|
||||
@@ -20,21 +20,21 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
}
|
||||
@ObservationIgnored public var requestHostUpgrade: (() -> Void)?
|
||||
@ObservationIgnored public var resolveInitialHostEntitlement: (() async -> Void)?
|
||||
public var lastHostAccessError: HostAccessError?
|
||||
|
||||
public func isLocked(_ host: Host) -> Bool { hostAccess.isLocked(host.id, in: hosts) }
|
||||
|
||||
public func requireHostAccess(_ host: Host) throws {
|
||||
for node in route(for: host) {
|
||||
public func requireHostAccess(_ host: Host, protocol connectionProtocol: HostTerminalProtocol) throws {
|
||||
for node in connectionProtocol == .telnet ? [host] : route(for: host) {
|
||||
try hostAccess.requireAccess(to: node.id, in: hosts)
|
||||
}
|
||||
}
|
||||
|
||||
public func resolveHostAccess(_ host: Host) async throws {
|
||||
do { try requireHostAccess(host) } catch HostAccessError.verifyingEntitlement {
|
||||
public func resolveHostAccess(
|
||||
_ host: Host, protocol connectionProtocol: HostTerminalProtocol
|
||||
) async throws {
|
||||
do { try requireHostAccess(host, protocol: connectionProtocol) } catch HostAccessError.verifyingEntitlement {
|
||||
await resolveInitialHostEntitlement?()
|
||||
try Task.checkCancellation()
|
||||
try requireHostAccess(host)
|
||||
try requireHostAccess(host, protocol: connectionProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
public func requireSavingHosts(_ proposed: [Host]) throws {
|
||||
do {
|
||||
try hostAccess.requireSaving(proposed, in: hosts)
|
||||
lastHostAccessError = nil
|
||||
} catch let error as HostAccessError {
|
||||
lastHostAccessError = error
|
||||
if error == .hostLimit { requestHostUpgrade?() }
|
||||
throw error
|
||||
}
|
||||
@@ -117,8 +115,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
private func wakeBeforeConnecting(_ ids: [UUID]) {
|
||||
guard
|
||||
ids.allSatisfy({ id in
|
||||
guard let host = host(id) else { return true }
|
||||
return (try? requireHostAccess(host)) != nil
|
||||
(try? hostAccess.requireAccess(to: id, in: hosts)) != nil
|
||||
})
|
||||
else { return }
|
||||
for id in ids {
|
||||
@@ -1014,13 +1011,13 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
}
|
||||
|
||||
public func isEligible(_ host: Host, for useCase: HostUseCase) -> Bool {
|
||||
guard (try? requireHostAccess(host)) != nil else { return false }
|
||||
guard (try? requireHostAccess(host, protocol: useCase.accessProtocol(for: host))) != nil else { return false }
|
||||
if host.isTailscaleManaged && [.jumpHost, .keyExport, .portForwarding].contains(useCase) { return false }
|
||||
return host.supports(useCase.protocolRequirement)
|
||||
}
|
||||
|
||||
public func require(_ host: Host, for useCase: HostUseCase) throws {
|
||||
try requireHostAccess(host)
|
||||
try requireHostAccess(host, protocol: useCase.accessProtocol(for: host))
|
||||
guard isEligible(host, for: useCase) else {
|
||||
throw HostProtocolUnavailableError(useCase: useCase)
|
||||
}
|
||||
@@ -1052,6 +1049,29 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
|
||||
// MARK: - Writes
|
||||
|
||||
@discardableResult
|
||||
public func duplicateHost(_ proposed: Host, name: String) async throws -> Bool {
|
||||
guard let source = host(proposed.id), !source.isTailscaleManaged else { return false }
|
||||
var copy = source
|
||||
if source.identityID.flatMap(identity)?.isHostCustom == true { copy.identityID = nil }
|
||||
if source.telnetIdentityID.flatMap(identity)?.isHostCustom == true { copy.telnetIdentityID = nil }
|
||||
if source.proxy?.identityID.flatMap(identity)?.isHostCustom == true {
|
||||
copy.proxy?.identityID = nil
|
||||
copy.proxy?.username = ""
|
||||
}
|
||||
if source.telnetProxy?.identityID.flatMap(identity)?.isHostCustom == true {
|
||||
copy.telnetProxy?.identityID = nil
|
||||
copy.telnetProxy?.username = ""
|
||||
}
|
||||
copy.id = UUID()
|
||||
copy.name = name
|
||||
copy.createdAt = nil
|
||||
try await resolveSavingHostsAccess([copy])
|
||||
try Task.checkCancellation()
|
||||
guard host(source.id) != nil else { return false }
|
||||
return saveHost(copy)
|
||||
}
|
||||
|
||||
public func upsert(_ host: Host) { _ = saveHost(host) }
|
||||
|
||||
@discardableResult
|
||||
|
||||
@@ -21,6 +21,14 @@ public nonisolated enum HostUseCase: Hashable, Sendable {
|
||||
case .sshTerminal, .sftp, .jumpHost, .metrics, .portForwarding, .snippetExecution, .keyExport: .ssh
|
||||
}
|
||||
}
|
||||
|
||||
public func accessProtocol(for host: Host) -> HostTerminalProtocol {
|
||||
switch self {
|
||||
case .preferredTerminal: host.preferredTerminalProtocol ?? .ssh
|
||||
case .telnetTerminal: .telnet
|
||||
default: .ssh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated struct HostProtocolUnavailableError: LocalizedError, Equatable, Sendable {
|
||||
|
||||
@@ -11,18 +11,24 @@ extension MobileAppModel {
|
||||
LocalDiscoveryModel(service: LANHostDiscovery(connectionProtocol: connectionProtocol))
|
||||
}
|
||||
|
||||
func requestWake(_ host: Host, configure: () -> Void) -> String? {
|
||||
switch library.requestWake(host) {
|
||||
case .accessDenied:
|
||||
if library.isLocked(host) { pro.presentPaywall() }
|
||||
func requestWake(_ host: Host, configure: () -> Void) async -> String? {
|
||||
do {
|
||||
switch try await library.requestWake(host) {
|
||||
case .configurationRequired:
|
||||
configure()
|
||||
return nil
|
||||
case .sent:
|
||||
return String(localized: AppStrings.LocalNetwork.sent)
|
||||
case .failed:
|
||||
return String(localized: AppStrings.LocalNetwork.wakeFailed)
|
||||
}
|
||||
} catch HostAccessError.lockedHost {
|
||||
pro.presentPaywall()
|
||||
return nil
|
||||
case .configurationRequired:
|
||||
configure()
|
||||
} catch is CancellationError {
|
||||
return nil
|
||||
case .sent:
|
||||
return String(localized: AppStrings.LocalNetwork.sent)
|
||||
case .failed:
|
||||
return String(localized: AppStrings.LocalNetwork.wakeFailed)
|
||||
} catch {
|
||||
return HostAccessError.presentationDescription(of: error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -547,22 +547,15 @@ final class MobileAppModel {
|
||||
if let storageError = library.storageError { error = storageError }
|
||||
}
|
||||
|
||||
func duplicateHost(_ host: Host) {
|
||||
guard !host.isTailscaleManaged else { return }
|
||||
var copy = host
|
||||
if host.identityID.flatMap(library.identity)?.isHostCustom == true { copy.identityID = nil }
|
||||
if host.telnetIdentityID.flatMap(library.identity)?.isHostCustom == true { copy.telnetIdentityID = nil }
|
||||
if host.proxy?.identityID.flatMap(library.identity)?.isHostCustom == true {
|
||||
copy.proxy?.identityID = nil
|
||||
copy.proxy?.username = ""
|
||||
func duplicateHost(_ host: Host) async {
|
||||
do {
|
||||
let saved = try await library.duplicateHost(
|
||||
host, name: String(localized: AppStrings.Vault.duplicatedHostName(host.name)))
|
||||
if !saved { error = library.storageError }
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
self.error = HostAccessError.presentationDescription(of: error)
|
||||
}
|
||||
if host.telnetProxy?.identityID.flatMap(library.identity)?.isHostCustom == true {
|
||||
copy.telnetProxy?.identityID = nil
|
||||
copy.telnetProxy?.username = ""
|
||||
}
|
||||
copy.id = UUID()
|
||||
copy.name = String(localized: AppStrings.Vault.duplicatedHostName(host.name))
|
||||
library.upsert(copy)
|
||||
}
|
||||
|
||||
func lastConnectedAt(for hostID: Host.ID) -> Date? {
|
||||
|
||||
@@ -151,12 +151,12 @@ final class MobileWorkspaceSessionModel {
|
||||
initialPath: String? = nil
|
||||
) -> UUID? {
|
||||
guard !isDiscarded, let host = app.library.host(hostID) else { return nil }
|
||||
do { try app.library.requireHostAccess(host) } catch {
|
||||
do { try app.library.requireHostAccess(host, protocol: kind.terminalProtocol ?? .ssh) } catch {
|
||||
if case HostAccessError.verifyingEntitlement = error {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await app.library.resolveHostAccess(host)
|
||||
try await app.library.resolveHostAccess(host, protocol: kind.terminalProtocol ?? .ssh)
|
||||
guard !isDiscarded else { return }
|
||||
_ = open(hostID: hostID, kind: kind, initialPath: initialPath)
|
||||
} catch {
|
||||
|
||||
@@ -855,7 +855,7 @@ struct MobileVaultView: View {
|
||||
}
|
||||
case .wake:
|
||||
Button(AppStrings.LocalNetwork.wake.swiftUIKey, systemImage: "power") {
|
||||
wakeNotice = app.requestWake(host) { editingHost = host }
|
||||
Task { wakeNotice = await app.requestWake(host) { editingHost = host } }
|
||||
}
|
||||
case .edit:
|
||||
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") { editingHost = host }
|
||||
@@ -865,7 +865,7 @@ struct MobileVaultView: View {
|
||||
}
|
||||
case .duplicate:
|
||||
Button(AppStrings.Common.duplicate.swiftUIKey, systemImage: "plus.square.on.square") {
|
||||
app.duplicateHost(host)
|
||||
Task { await app.duplicateHost(host) }
|
||||
}.disabled(host.isTailscaleManaged)
|
||||
case .delete:
|
||||
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
|
||||
|
||||
@@ -485,16 +485,20 @@ final class MobileWorkspace: Identifiable {
|
||||
|
||||
func insertTerminalText(_ text: String) {
|
||||
guard connected else { return }
|
||||
if let host = connectionHost(descriptor.hostID) {
|
||||
do { try hosts.requireHostAccess(host) } catch {
|
||||
operationError = error.localizedDescription
|
||||
return
|
||||
}
|
||||
do { try requireTerminalInputAccess() } catch {
|
||||
operationError = error.localizedDescription
|
||||
return
|
||||
}
|
||||
_ = terminal.paste(text: text)
|
||||
terminal.requestFocus()
|
||||
}
|
||||
|
||||
func requireTerminalInputAccess() throws {
|
||||
if let host = connectionHost(descriptor.hostID) {
|
||||
try hosts.requireHostAccess(host, protocol: descriptor.kind.terminalProtocol ?? .ssh)
|
||||
}
|
||||
}
|
||||
|
||||
func generateCommand(
|
||||
using generator: any CommandGenerating,
|
||||
readViewport: @MainActor (MobileWorkspace) -> String? = { $0.readViewportText() }
|
||||
@@ -1438,7 +1442,7 @@ final class MobileWorkspace: Identifiable {
|
||||
attempt = Task { [weak self, weak hosts] in
|
||||
guard let self, let hosts else { return }
|
||||
do {
|
||||
try await hosts.resolveHostAccess(host)
|
||||
try await hosts.resolveHostAccess(host, protocol: .telnet)
|
||||
var configuration = try hosts.telnetConnectionConfiguration(for: host)
|
||||
if let usernameHint { configuration.username = usernameHint }
|
||||
if let password = self.advisoryTelnetPassword {
|
||||
|
||||
@@ -293,20 +293,21 @@ struct MobileWorkspaceTests {
|
||||
#expect(try store.load().hosts.first?.id == host.id)
|
||||
}
|
||||
|
||||
@Test func freeMetricsLimitCountsAllSelectionsAndDeduplicates() {
|
||||
@Test func metricsHasNoIndependentSelectionLimitAndDeduplicates() {
|
||||
let library = HostLibraryModel()
|
||||
let hosts = (0..<5).map { TermindDomain.Host(name: "Host \($0)", host: "example.invalid") }
|
||||
for host in hosts { library.upsert(host) }
|
||||
let metrics = MobileMetricsModel(
|
||||
preferences: MetricsPreferences(), hosts: library, services: .testing)
|
||||
metrics.pause()
|
||||
#expect(metrics.add(hosts[0].id))
|
||||
#expect(metrics.add(hosts[0].id))
|
||||
#expect(metrics.add(hosts[1].id))
|
||||
#expect(metrics.add(hosts[2].id))
|
||||
#expect(!metrics.add(hosts[3].id))
|
||||
#expect(metrics.add(hosts[4].id))
|
||||
#expect(metrics.selections.count == 4)
|
||||
defer { metrics.core.shutdown() }
|
||||
for host in hosts {
|
||||
let added = metrics.add(host.id)
|
||||
#expect(added)
|
||||
}
|
||||
let repeated = metrics.add(hosts[0].id)
|
||||
#expect(repeated)
|
||||
#expect(metrics.selections.map(\.id) == hosts.map(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindInfrastructure
|
||||
import TermindPersistence
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite @MainActor
|
||||
struct MobileHostAdmissionTests {
|
||||
private func makeApp(status: EntitlementStatus, count: Int = 4) throws -> MobileAppModel {
|
||||
var hosts = (0..<count).map {
|
||||
Host(
|
||||
name: "Host\($0)", host: "host\($0).invalid",
|
||||
createdAt: Date(timeIntervalSince1970: Double($0)), telnetEnabled: true)
|
||||
}
|
||||
if count == 4 { hosts[0].jumpHostID = hosts[3].id }
|
||||
var dependencies = MobileAppDependencies.testing()
|
||||
dependencies.proAccess = .testing(status: status)
|
||||
let container = try TermindPersistence.makeContainer(inMemory: true)
|
||||
_ = try HostPersistence(context: container.mainContext).save(.init(hosts: hosts))
|
||||
dependencies.container = container
|
||||
return try MobileAppModel(dependencies: dependencies)
|
||||
}
|
||||
|
||||
@Test(arguments: [EntitlementStatus.free, .loading])
|
||||
func telnetWorkspaceAndPickerIgnoreTheIndependentSSHJump(status: EntitlementStatus) throws {
|
||||
let app = try makeApp(status: status)
|
||||
defer {
|
||||
app.testWorkspaceSession.closeAll()
|
||||
app.metrics.core.shutdown()
|
||||
app.outbox.stop()
|
||||
}
|
||||
let hosts = app.library.hosts.sorted { $0.name < $1.name }
|
||||
app.library.resolveInitialHostEntitlement = { Issue.record("The Telnet target is already admitted") }
|
||||
let opened = app.testWorkspaceSession.open(hostID: hosts[0].id, kind: .telnet)
|
||||
#expect(opened != nil)
|
||||
#expect(app.workspaces.first?.descriptor.kind == .telnet)
|
||||
let telnet = try #require(app.workspaces.first)
|
||||
try telnet.requireTerminalInputAccess()
|
||||
let picker = MobileMultiHostPickerModel(library: app.library, useCase: .telnetTerminal, selected: [])
|
||||
#expect(picker.hosts.contains { $0.id == hosts[0].id })
|
||||
#expect(!app.pro.isPaywallPresented)
|
||||
app.library.hostAccess.entitlement = .free
|
||||
let before = app.workspaces.count
|
||||
let ssh = app.testWorkspaceSession.open(hostID: hosts[0].id, kind: .ssh)
|
||||
let sftp = app.testWorkspaceSession.open(hostID: hosts[0].id, kind: .sftp)
|
||||
let lockedTelnet = app.testWorkspaceSession.open(hostID: hosts[3].id, kind: .telnet)
|
||||
#expect(ssh == nil && sftp == nil && lockedTelnet == nil)
|
||||
#expect(app.workspaces.count == before)
|
||||
#expect(app.pro.isPaywallPresented)
|
||||
let sshInput = MobileWorkspace(
|
||||
descriptor: .init(hostID: hosts[0].id, title: hosts[0].name, kind: .ssh), hosts: app.library)
|
||||
#expect(throws: HostAccessError.lockedHost(hosts[3].id)) { try sshInput.requireTerminalInputAccess() }
|
||||
}
|
||||
|
||||
@Test(arguments: [EntitlementStatus.purchased(.purchased), .free, .verificationFailed])
|
||||
func duplicateWaitsAndPresentsOnlyTheApplicableAccessOutcome(status: EntitlementStatus) async throws {
|
||||
let app = try makeApp(status: .loading, count: 3)
|
||||
defer {
|
||||
app.metrics.core.shutdown()
|
||||
app.outbox.stop()
|
||||
}
|
||||
let hosts = app.library.hosts
|
||||
var continuation: CheckedContinuation<Void, Never>?
|
||||
app.library.resolveInitialHostEntitlement = {
|
||||
await withCheckedContinuation { continuation = $0 }
|
||||
app.library.hostAccess.entitlement = status
|
||||
}
|
||||
defer { app.library.resolveInitialHostEntitlement = nil }
|
||||
let operation = Task { await app.duplicateHost(hosts[0]) }
|
||||
for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) }
|
||||
#expect(continuation != nil)
|
||||
#expect(app.library.hosts == hosts)
|
||||
continuation?.resume()
|
||||
await operation.value
|
||||
#expect(app.library.hosts.count == (status.hasProAccess ? 4 : 3))
|
||||
#expect(app.pro.isPaywallPresented == (status == .free))
|
||||
#expect(
|
||||
app.error
|
||||
== (status == .verificationFailed
|
||||
? HostAccessError.entitlementVerificationFailed.localizedDescription : nil))
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,40 @@ import Testing
|
||||
|
||||
@Suite @MainActor
|
||||
struct MobileMetricsHostAccessTests {
|
||||
@Test func lockedSelectionsPersistWithoutSamplersAndResumeAfterUpgrade() async throws {
|
||||
let identity = Identity(name: "Metrics", username: "root", credentialRef: "metrics-password")
|
||||
let secrets = InMemorySecretStore()
|
||||
try secrets.setString("test-only", for: identity.credentialRef, storage: identity.passwordStorage)
|
||||
let hosts = (0..<4).map {
|
||||
TermindDomain.Host(
|
||||
name: "Host \($0)", host: "host.invalid", identityID: identity.id,
|
||||
createdAt: Date(timeIntervalSince1970: Double($0)))
|
||||
}
|
||||
let library = HostLibraryModel(hosts: hosts, identities: [identity], secrets: secrets)
|
||||
library.hostAccess.entitlement = .free
|
||||
let preferences = MobileLockedMetricsPreferences()
|
||||
let recorder = MobileAdmissionSamplerRecorder()
|
||||
let metrics = MobileMetricsModel(
|
||||
preferences: preferences, hosts: library,
|
||||
services: MobileMetricsServices(makeSampler: { recorder.makeSampler() }))
|
||||
defer { metrics.core.shutdown() }
|
||||
let added = metrics.add(hosts[3].id)
|
||||
#expect(added)
|
||||
let server = try #require(metrics.servers.first)
|
||||
metrics.core.reconcile()
|
||||
#expect(server.isHostLocked)
|
||||
#expect(preferences.values.map(\.id) == [hosts[3].id])
|
||||
#expect(metrics.selections.map(\.id) == [hosts[3].id])
|
||||
#expect(recorder.count == 0)
|
||||
|
||||
library.hostAccess.entitlement = .purchased(.purchased)
|
||||
metrics.core.reconcile()
|
||||
for _ in 0..<200 where recorder.count == 0 { try await Task.sleep(for: .milliseconds(5)) }
|
||||
#expect(!server.isHostLocked)
|
||||
#expect(recorder.count == 1)
|
||||
#expect(preferences.values.map(\.id) == [hosts[3].id])
|
||||
}
|
||||
|
||||
@Test func failedAccessUsesErrorIndicatorRatherThanAnIndefiniteSpinner() {
|
||||
let server = ServerMetric(selection: MetricsSelection(id: UUID()), name: "Host")
|
||||
server.hostAccessError = .entitlementVerificationFailed
|
||||
@@ -49,3 +83,26 @@ struct MobileMetricsHostAccessTests {
|
||||
#expect(inspected)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private final class MobileLockedMetricsPreferences: MetricsConfigurationStoring {
|
||||
var values: [MetricsSelection] = []
|
||||
func load() -> [MetricsSelection] { values }
|
||||
func save(_ selections: [MetricsSelection]) { values = selections }
|
||||
}
|
||||
|
||||
private nonisolated final class MobileAdmissionSamplerRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var creations = 0
|
||||
var count: Int { lock.withLock { creations } }
|
||||
func makeSampler() -> any MetricsSampling {
|
||||
lock.withLock { creations += 1 }
|
||||
return MobileAdmissionMetricsSampler()
|
||||
}
|
||||
}
|
||||
|
||||
private actor MobileAdmissionMetricsSampler: MetricsSampling {
|
||||
func connect(_ configuration: SSHConnectionConfiguration) async throws { throw CancellationError() }
|
||||
func sample() async throws -> MetricsSample { throw CancellationError() }
|
||||
func gpu() async throws -> NvidiaSnapshot? { nil }
|
||||
func disconnect() async {}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ struct MobileMultiHostPickerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func batchMetricsAdditionIsAtomicAndUsesTheCurrentEntitlement() {
|
||||
func batchMetricsAdditionHasNoIndependentLimitAndDeduplicates() {
|
||||
let hosts = (0..<5).map { Host(name: "Host \($0)", host: "host\($0).invalid") }
|
||||
let preferences = PickerMetricsPreferences()
|
||||
let metrics = MobileMetricsModel(
|
||||
@@ -80,15 +80,18 @@ struct MobileMultiHostPickerTests {
|
||||
defer { metrics.core.shutdown() }
|
||||
metrics.pause()
|
||||
|
||||
#expect(metrics.add([hosts[0].id]))
|
||||
let saved = preferences.values
|
||||
#expect(!metrics.add(hosts[1...3].map(\.id)))
|
||||
#expect(preferences.values == saved)
|
||||
#expect(metrics.selections.map(\.id) == [hosts[0].id])
|
||||
#expect(metrics.add([hosts[0].id, hosts[1].id, hosts[1].id, hosts[2].id]))
|
||||
#expect(metrics.selections.count == 3)
|
||||
#expect(metrics.add(hosts[3...4].map(\.id)))
|
||||
let initial = metrics.add([hosts[0].id])
|
||||
#expect(initial)
|
||||
let batch = metrics.add(hosts[1...3].map(\.id))
|
||||
#expect(batch)
|
||||
#expect(metrics.selections.map(\.id) == Array(hosts.prefix(4)).map(\.id))
|
||||
let repeated = metrics.add([hosts[0].id, hosts[1].id, hosts[1].id, hosts[2].id])
|
||||
#expect(repeated)
|
||||
#expect(metrics.selections.count == 4)
|
||||
let final = metrics.add(hosts[3...4].map(\.id))
|
||||
#expect(final)
|
||||
#expect(metrics.selections.map(\.id) == hosts.map(\.id))
|
||||
#expect(preferences.values.map(\.id) == hosts.map(\.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -108,7 +111,7 @@ struct MobileMultiHostPickerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func mobileMetricsCanSwapAtTheFreeLimitAndRemoveAllHosts() {
|
||||
func mobileMetricsCanReplaceAndRemoveAllHosts() {
|
||||
let hosts = (0..<4).map { Host(name: "Host \($0)", host: "host\($0).invalid") }
|
||||
let preferences = PickerMetricsPreferences()
|
||||
let metrics = MobileMetricsModel(
|
||||
|
||||
@@ -170,23 +170,25 @@ struct MobileVaultFeatureTests {
|
||||
])
|
||||
}
|
||||
|
||||
@Test func duplicatingAHostPersistsAnIndependentCopyWithTheSameConfiguration() throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
@Test func duplicatingAHostPersistsAnIndependentCopyWithTheSameConfiguration() async throws {
|
||||
let group = HostGroup(name: "Production")
|
||||
let jump = TermindDomain.Host(name: "Jump", host: "jump.example.invalid")
|
||||
app.library.upsert(group)
|
||||
app.library.upsert(jump)
|
||||
let host = TermindDomain.Host(
|
||||
name: "Remote", host: "remote.example.invalid", port: 2222, identityID: UUID(),
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
jumpHostID: jump.id,
|
||||
proxy: HostProxy(kind: .socks5, hostname: "proxy.example.invalid", port: 1080),
|
||||
sftpChannelInitialPath: "/srv/app", groupID: group.id, tags: ["production", "api"],
|
||||
colorTag: .teal, detectedDistribution: DetectedDistribution(id: "debian"),
|
||||
shellIntegrationMode: .enabled, portForwardingMaxConnections: 12)
|
||||
app.library.upsert(host)
|
||||
var dependencies = MobileAppDependencies.testing()
|
||||
let container = try TermindPersistence.makeContainer(inMemory: true)
|
||||
_ = try HostPersistence(context: container.mainContext).save(.init(hosts: [jump, host], groups: [group]))
|
||||
dependencies.container = container
|
||||
let app = try MobileAppModel(dependencies: dependencies)
|
||||
let original = try #require(app.library.host(host.id))
|
||||
|
||||
app.duplicateHost(original)
|
||||
await app.duplicateHost(original)
|
||||
|
||||
#expect(app.library.hosts.count == 3)
|
||||
#expect(app.library.host(original.id) == original)
|
||||
@@ -197,6 +199,9 @@ struct MobileVaultFeatureTests {
|
||||
expected.id = copy.id
|
||||
expected.name = copy.name
|
||||
expected.sortIndex = copy.sortIndex
|
||||
#expect(copy.createdAt != nil)
|
||||
#expect(copy.createdAt != original.createdAt)
|
||||
expected.createdAt = copy.createdAt
|
||||
#expect(copy == expected)
|
||||
app.outbox.stop()
|
||||
}
|
||||
|
||||
@@ -2,14 +2,60 @@ import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindInfrastructure
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite @MainActor
|
||||
struct MobileWakeAccessTests {
|
||||
@Test(arguments: [EntitlementStatus.purchased(.purchased), .free, .verificationFailed])
|
||||
func pendingManualWakeResumesOnceWithTypedAccessPresentation(status: EntitlementStatus) async throws {
|
||||
var dependencies = MobileAppDependencies.testing()
|
||||
dependencies.proAccess = .testing(status: .free)
|
||||
let app = try MobileAppModel(dependencies: dependencies)
|
||||
defer {
|
||||
app.metrics.core.shutdown()
|
||||
app.outbox.stop()
|
||||
}
|
||||
app.library.hostAccess.entitlement = .unrestrictedForTesting
|
||||
for index in 0..<4 {
|
||||
var host = TermindDomain.Host(name: "Host \(index)", host: "host.invalid")
|
||||
host.wakeOnLAN.macAddress = "02:11:22:33:44:55"
|
||||
app.library.upsert(host)
|
||||
}
|
||||
let target = try #require(app.library.hosts.last)
|
||||
let sender = RecordedMobileWakeSender()
|
||||
app.library.wakeOnLAN = WakeOnLANController(sender: sender)
|
||||
app.library.hostAccess.entitlement = .loading
|
||||
var continuation: CheckedContinuation<Void, Never>?
|
||||
app.library.resolveInitialHostEntitlement = {
|
||||
await withCheckedContinuation { continuation = $0 }
|
||||
app.library.hostAccess.entitlement = status
|
||||
}
|
||||
defer { app.library.resolveInitialHostEntitlement = nil }
|
||||
var configured = false
|
||||
let operation = Task { await app.requestWake(target) { configured = true } }
|
||||
for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) }
|
||||
#expect(continuation != nil)
|
||||
#expect(sender.count == 0)
|
||||
#expect(!app.pro.isPaywallPresented)
|
||||
continuation?.resume()
|
||||
let notice = await operation.value
|
||||
#expect(!configured)
|
||||
#expect(app.pro.isPaywallPresented == (status == .free))
|
||||
#expect(sender.count == (status.hasProAccess ? 1 : 0))
|
||||
if status.hasProAccess {
|
||||
#expect(notice == String(localized: AppStrings.LocalNetwork.sent))
|
||||
} else if status == .verificationFailed {
|
||||
#expect(notice == HostAccessError.entitlementVerificationFailed.localizedDescription)
|
||||
} else {
|
||||
#expect(notice == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["", "02:11:22:33:44:55"])
|
||||
func lockedManualWakePresentsPaywallWithoutConfigurationOrPacket(macAddress: String) throws {
|
||||
func lockedManualWakePresentsPaywallWithoutConfigurationOrPacket(macAddress: String) async throws {
|
||||
var dependencies = MobileAppDependencies.testing()
|
||||
dependencies.proAccess = .testing(status: .free)
|
||||
let app = try MobileAppModel(dependencies: dependencies)
|
||||
@@ -28,7 +74,8 @@ struct MobileWakeAccessTests {
|
||||
app.library.wakeOnLAN = WakeOnLANController(sender: UnexpectedMobileWakeSender())
|
||||
#expect(app.library.isLocked(locked))
|
||||
var configured = false
|
||||
#expect(app.requestWake(locked) { configured = true } == nil)
|
||||
let notice = await app.requestWake(locked) { configured = true }
|
||||
#expect(notice == nil)
|
||||
#expect(app.pro.isPaywallPresented)
|
||||
#expect(!configured)
|
||||
}
|
||||
@@ -37,3 +84,10 @@ struct MobileWakeAccessTests {
|
||||
private nonisolated struct UnexpectedMobileWakeSender: WakeOnLANSending {
|
||||
func send(to address: MACAddress) throws { Issue.record("A locked host must not send a Wake packet") }
|
||||
}
|
||||
|
||||
private nonisolated final class RecordedMobileWakeSender: WakeOnLANSending, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var packets = 0
|
||||
var count: Int { lock.withLock { packets } }
|
||||
func send(to address: MACAddress) { lock.withLock { packets += 1 } }
|
||||
}
|
||||
@@ -11,18 +11,24 @@ extension AppModel {
|
||||
LocalDiscoveryModel(service: LANHostDiscovery(connectionProtocol: connectionProtocol))
|
||||
}
|
||||
|
||||
func requestWake(_ host: Host, configure: () -> Void) -> String? {
|
||||
switch hostLibrary.requestWake(host) {
|
||||
case .accessDenied:
|
||||
if hostLibrary.isLocked(host) { proAccess.presentPaywall() }
|
||||
func requestWake(_ host: Host, configure: () -> Void) async -> String? {
|
||||
do {
|
||||
switch try await hostLibrary.requestWake(host) {
|
||||
case .configurationRequired:
|
||||
configure()
|
||||
return nil
|
||||
case .sent:
|
||||
return String(localized: AppStrings.LocalNetwork.sent)
|
||||
case .failed:
|
||||
return String(localized: AppStrings.LocalNetwork.wakeFailed)
|
||||
}
|
||||
} catch HostAccessError.lockedHost {
|
||||
proAccess.presentPaywall()
|
||||
return nil
|
||||
case .configurationRequired:
|
||||
configure()
|
||||
} catch is CancellationError {
|
||||
return nil
|
||||
case .sent:
|
||||
return String(localized: AppStrings.LocalNetwork.sent)
|
||||
case .failed:
|
||||
return String(localized: AppStrings.LocalNetwork.wakeFailed)
|
||||
} catch {
|
||||
return HostAccessError.presentationDescription(of: error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-9
@@ -34,6 +34,14 @@ nonisolated enum HostOpenTarget: Hashable, Sendable {
|
||||
case eternalTerminalSession
|
||||
case telnetSession
|
||||
case sftpChannel
|
||||
|
||||
func accessProtocol(for host: Host) -> HostTerminalProtocol {
|
||||
switch self {
|
||||
case .preferredTerminal: host.preferredTerminalProtocol ?? .ssh
|
||||
case .telnetSession: .telnet
|
||||
default: .ssh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App-wide state shared by every window: the saved hosts, and the one code path
|
||||
@@ -1064,7 +1072,7 @@ final class AppModel {
|
||||
) -> Workspace? {
|
||||
guard
|
||||
admitHostOpen(
|
||||
host, in: window,
|
||||
host, protocol: target.accessProtocol(for: host), in: window,
|
||||
whenResolved: { [weak self, weak window] in
|
||||
guard let self, let window else { return }
|
||||
self.open(host, as: target, in: window)
|
||||
@@ -1101,7 +1109,7 @@ final class AppModel {
|
||||
for node in target.route.hosts {
|
||||
guard
|
||||
admitHostOpen(
|
||||
node, in: window,
|
||||
node, protocol: target.connectionProtocol, in: window,
|
||||
whenResolved: { [weak self, weak window] in
|
||||
guard let self, let window else { return }
|
||||
self.openQuickConnect(target, in: window)
|
||||
@@ -1111,7 +1119,7 @@ final class AppModel {
|
||||
for id in target.route.sourceHostIDs.values {
|
||||
if let saved = hosts.host(id),
|
||||
!admitHostOpen(
|
||||
saved, in: window,
|
||||
saved, protocol: target.connectionProtocol, in: window,
|
||||
whenResolved: { [weak self, weak window] in
|
||||
guard let self, let window else { return }
|
||||
self.openQuickConnect(target, in: window)
|
||||
@@ -1290,19 +1298,20 @@ final class AppModel {
|
||||
) {
|
||||
if let pending = hosts.first(where: {
|
||||
do {
|
||||
try self.hosts.requireHostAccess($0)
|
||||
try self.hosts.requireHostAccess($0, protocol: target.accessProtocol(for: $0))
|
||||
return false
|
||||
} catch {
|
||||
return (error as? HostAccessError) == .verifyingEntitlement
|
||||
}
|
||||
}) {
|
||||
_ = admitHostOpen(pending, in: window) { [weak self, weak window] in
|
||||
_ = admitHostOpen(pending, protocol: target.accessProtocol(for: pending), in: window) {
|
||||
[weak self, weak window] in
|
||||
guard let self, let window else { return }
|
||||
self.open(hosts, as: target, layout: layout, in: window)
|
||||
}
|
||||
return
|
||||
}
|
||||
let hosts = hosts.filter { admitHostOpen($0, in: window) }
|
||||
let hosts = hosts.filter { admitHostOpen($0, protocol: target.accessProtocol(for: $0), in: window) }
|
||||
guard !hosts.isEmpty else { return }
|
||||
configureConnectionHistory(in: window)
|
||||
switch layout {
|
||||
@@ -1316,17 +1325,18 @@ final class AppModel {
|
||||
}
|
||||
|
||||
private func admitHostOpen(
|
||||
_ host: Host, in window: WindowModel, whenResolved: (() -> Void)? = nil
|
||||
_ host: Host, protocol connectionProtocol: HostTerminalProtocol,
|
||||
in window: WindowModel, whenResolved: (() -> Void)? = nil
|
||||
) -> Bool {
|
||||
do {
|
||||
try hosts.requireHostAccess(host)
|
||||
try hosts.requireHostAccess(host, protocol: connectionProtocol)
|
||||
return true
|
||||
} catch {
|
||||
if case HostAccessError.verifyingEntitlement = error {
|
||||
Task { [weak self, weak window] in
|
||||
guard let self, let window else { return }
|
||||
do {
|
||||
try await hosts.resolveHostAccess(host)
|
||||
try await hosts.resolveHostAccess(host, protocol: connectionProtocol)
|
||||
whenResolved?()
|
||||
} catch {
|
||||
if case HostAccessError.lockedHost = error {
|
||||
|
||||
@@ -45,7 +45,9 @@ struct HostContextMenu: View {
|
||||
let showsTailscale = host.isTailscaleManaged || app.tailscale.peer(for: host) != nil
|
||||
if showsWake {
|
||||
Button(AppStrings.LocalNetwork.wake.swiftUIKey, systemImage: "power") {
|
||||
window.wakeNotice = app.requestWake(host) { window.openHostEditor(host: host) }
|
||||
Task {
|
||||
window.wakeNotice = await app.requestWake(host) { window.openHostEditor(host: host) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if showsTailscale {
|
||||
@@ -60,7 +62,7 @@ struct HostContextMenu: View {
|
||||
window.openHostEditor(host: host)
|
||||
}
|
||||
Button(AppStrings.Common.duplicate.swiftUIKey, systemImage: MenuSymbol.duplicate) {
|
||||
HostMenuEntries.duplicate(host, in: app)
|
||||
Task { await HostMenuEntries.duplicate(host, in: app, window: window) }
|
||||
}
|
||||
.disabled(host.isTailscaleManaged)
|
||||
Divider()
|
||||
@@ -204,7 +206,9 @@ enum HostMenuEntries {
|
||||
NativeMenuEntry(
|
||||
title: AppStrings.LocalNetwork.wake, symbol: "power"
|
||||
) {
|
||||
window.wakeNotice = app.requestWake(host) { window.openHostEditor(host: host) }
|
||||
Task {
|
||||
window.wakeNotice = await app.requestWake(host) { window.openHostEditor(host: host) }
|
||||
}
|
||||
})
|
||||
}
|
||||
if showsTailscale {
|
||||
@@ -226,31 +230,21 @@ enum HostMenuEntries {
|
||||
title: AppStrings.Common.duplicate, symbol: MenuSymbol.duplicate,
|
||||
isEnabled: !host.isTailscaleManaged
|
||||
) {
|
||||
duplicate(host, in: app)
|
||||
Task { await duplicate(host, in: app, window: window) }
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
static func duplicate(_ host: Host, in app: AppModel) {
|
||||
var copy = host
|
||||
if host.identityID.flatMap(app.hostLibrary.identity)?.isHostCustom == true {
|
||||
copy.identityID = nil
|
||||
static func duplicate(_ host: Host, in app: AppModel, window: WindowModel) async {
|
||||
do {
|
||||
let saved = try await app.hostLibrary.duplicateHost(
|
||||
host, name: String(localized: AppStrings.Vault.duplicatedHostName(host.name)))
|
||||
if !saved { window.hostAccessError = app.hostLibrary.storageError }
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
window.hostAccessError = HostAccessError.presentationDescription(of: error)
|
||||
}
|
||||
if host.proxy?.identityID.flatMap(app.hostLibrary.identity)?.isHostCustom == true {
|
||||
copy.proxy?.identityID = nil
|
||||
copy.proxy?.username = ""
|
||||
}
|
||||
if host.telnetIdentityID.flatMap(app.hostLibrary.identity)?.isHostCustom == true {
|
||||
copy.telnetIdentityID = nil
|
||||
}
|
||||
if host.telnetProxy?.identityID.flatMap(app.hostLibrary.identity)?.isHostCustom == true {
|
||||
copy.telnetProxy?.identityID = nil
|
||||
copy.telnetProxy?.username = ""
|
||||
}
|
||||
copy.id = UUID()
|
||||
copy.name = String(localized: AppStrings.Vault.duplicatedHostName(host.name))
|
||||
app.hostLibrary.upsert(copy)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -386,27 +386,6 @@ enum SettingsDraftPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProLockedSettingsPage: View {
|
||||
@Environment(AppModel.self) private var app
|
||||
let title: LocalizedStringResource
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Metrics.Space.l) {
|
||||
Spacer()
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 34))
|
||||
.foregroundStyle(.tint)
|
||||
Text(title).font(.title2.bold())
|
||||
Text(AppStrings.Settings.thisConfigurationIsAvailableWithTermindPro)
|
||||
.foregroundStyle(.secondary)
|
||||
Button(AppStrings.Settings.viewTermindPro.swiftUIKey) { app.proAccess.presentPaywall() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsFormPage<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ final class TelnetSessionModel: Identifiable {
|
||||
let viewport = latestViewport
|
||||
connectionTask = Task { [weak self] in
|
||||
do {
|
||||
try await store.resolveHostAccess(host)
|
||||
try await store.resolveHostAccess(host, protocol: .telnet)
|
||||
var configuration = try store.telnetConnectionConfiguration(for: host)
|
||||
if let usernameHint = self?.usernameHint { configuration.username = usernameHint }
|
||||
if let password = self?.advisoryPassword {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindInfrastructure
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite @MainActor
|
||||
struct HostAdmissionTests {
|
||||
private func fixtures() -> [Host] {
|
||||
var hosts = (0..<4).map {
|
||||
Host(
|
||||
name: "Host\($0)", host: "host\($0).invalid",
|
||||
createdAt: Date(timeIntervalSince1970: Double($0)), telnetEnabled: true)
|
||||
}
|
||||
hosts[0].jumpHostID = hosts[3].id
|
||||
return hosts
|
||||
}
|
||||
|
||||
@Test(arguments: [EntitlementStatus.free, .loading])
|
||||
func telnetSingleBatchAndQuickConnectIgnoreTheSSHJump(status: EntitlementStatus) throws {
|
||||
let hosts = fixtures()
|
||||
let app = AppModel(hosts: HostLibraryModel(hosts: hosts), proAccess: .testing(status: status))
|
||||
let window = WindowModel()
|
||||
defer {
|
||||
window.closeAll()
|
||||
app.shutdownForTermination()
|
||||
}
|
||||
app.hostLibrary.resolveInitialHostEntitlement = { Issue.record("The Telnet target is already admitted") }
|
||||
let opened = app.open(hosts[0], as: .telnetSession, in: window)
|
||||
#expect(opened?.focusedPane.telnetSession != nil)
|
||||
app.open([hosts[0], hosts[1]], as: .telnetSession, layout: .oneWorkspace, in: window)
|
||||
#expect(window.workspaces.last?.panes.map(\.hostID) == [hosts[0].id, hosts[1].id])
|
||||
guard case .connection(let input) = QuickConnectParser.parse("telnet Host0 2323") else {
|
||||
Issue.record("Expected a Telnet command")
|
||||
return
|
||||
}
|
||||
let target = try QuickConnectResolver.resolve(input, in: app.hostLibrary)
|
||||
let quick = app.openQuickConnect(target, in: window)
|
||||
#expect(quick?.focusedPane.telnetSession != nil)
|
||||
#expect(!app.proAccess.isPaywallPresented)
|
||||
|
||||
app.hostLibrary.hostAccess.entitlement = .free
|
||||
let before = window.workspaces.count
|
||||
let ssh = app.open(hosts[0], as: .sshSession, in: window)
|
||||
let sftp = app.open(hosts[0], as: .sftpChannel, in: window)
|
||||
let lockedTelnet = app.open(hosts[3], as: .telnetSession, in: window)
|
||||
#expect(ssh == nil && sftp == nil && lockedTelnet == nil)
|
||||
#expect(window.workspaces.count == before)
|
||||
#expect(app.proAccess.isPaywallPresented)
|
||||
}
|
||||
|
||||
@Test func loadingBatchWaitsBeforeOpeningAnyTelnetPanes() async throws {
|
||||
let hosts = fixtures()
|
||||
let app = AppModel(hosts: HostLibraryModel(hosts: hosts), proAccess: .testing(status: .loading))
|
||||
let window = WindowModel()
|
||||
defer {
|
||||
window.closeAll()
|
||||
app.shutdownForTermination()
|
||||
}
|
||||
var continuation: CheckedContinuation<Void, Never>?
|
||||
app.hostLibrary.resolveInitialHostEntitlement = {
|
||||
await withCheckedContinuation { continuation = $0 }
|
||||
app.hostLibrary.hostAccess.entitlement = .purchased(.purchased)
|
||||
}
|
||||
defer { app.hostLibrary.resolveInitialHostEntitlement = nil }
|
||||
app.open([hosts[0], hosts[3]], as: .telnetSession, layout: .oneWorkspace, in: window)
|
||||
for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) }
|
||||
#expect(continuation != nil)
|
||||
#expect(window.workspaces.isEmpty)
|
||||
continuation?.resume()
|
||||
for _ in 0..<200 where window.workspaces.isEmpty { try await Task.sleep(for: .milliseconds(5)) }
|
||||
#expect(window.workspaces.count == 1)
|
||||
#expect(window.workspaces.first?.panes.map(\.hostID) == [hosts[0].id, hosts[3].id])
|
||||
#expect(!app.proAccess.isPaywallPresented)
|
||||
}
|
||||
|
||||
@Test(arguments: [EntitlementStatus.purchased(.purchased), .free, .verificationFailed])
|
||||
func duplicateWaitsAndPresentsOnlyTheApplicableAccessOutcome(status: EntitlementStatus) async throws {
|
||||
let hosts = Array(fixtures().prefix(3))
|
||||
let app = AppModel(hosts: HostLibraryModel(hosts: hosts), proAccess: .testing(status: .loading))
|
||||
let window = WindowModel()
|
||||
defer { app.shutdownForTermination() }
|
||||
var continuation: CheckedContinuation<Void, Never>?
|
||||
app.hostLibrary.resolveInitialHostEntitlement = {
|
||||
await withCheckedContinuation { continuation = $0 }
|
||||
app.hostLibrary.hostAccess.entitlement = status
|
||||
}
|
||||
defer { app.hostLibrary.resolveInitialHostEntitlement = nil }
|
||||
let operation = Task { await HostMenuEntries.duplicate(hosts[1], in: app, window: window) }
|
||||
for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) }
|
||||
#expect(continuation != nil)
|
||||
#expect(app.hostLibrary.hosts == hosts)
|
||||
continuation?.resume()
|
||||
await operation.value
|
||||
#expect(app.hostLibrary.hosts.count == (status.hasProAccess ? 4 : 3))
|
||||
#expect(app.proAccess.isPaywallPresented == (status == .free))
|
||||
#expect(
|
||||
window.hostAccessError
|
||||
== (status == .verificationFailed
|
||||
? HostAccessError.entitlementVerificationFailed.localizedDescription : nil))
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ struct QuickOpenTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func nativeHostMenuCanDuplicateWithoutChangingTheOriginalHost() throws {
|
||||
func nativeHostMenuCanDuplicateWithoutChangingTheOriginalHost() async throws {
|
||||
let host = Host(name: "Production", host: "server.invalid", port: 2222)
|
||||
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
|
||||
defer { app.shutdownForTermination() }
|
||||
@@ -34,6 +34,9 @@ struct QuickOpenTests {
|
||||
#expect(duplicate.isEnabled)
|
||||
|
||||
duplicate.action()
|
||||
for _ in 0..<200 where app.hostLibrary.hosts.count == 1 {
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
|
||||
#expect(app.hostLibrary.hosts.count == 2)
|
||||
let copy = try #require(app.hostLibrary.hosts.first { $0.id != host.id })
|
||||
|
||||
@@ -12,8 +12,47 @@ import struct TermindDomain.Host
|
||||
|
||||
@Suite @MainActor
|
||||
struct WakeOnLANAccessTests {
|
||||
@Test(arguments: [EntitlementStatus.purchased(.purchased), .free, .verificationFailed])
|
||||
func pendingManualWakeResumesOnceWithTypedAccessPresentation(status: EntitlementStatus) 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 pro = ProAccessController.testing(status: .free)
|
||||
let app = AppModel(hosts: HostLibraryModel(hosts: hosts), proAccess: pro)
|
||||
defer { app.shutdownForTermination() }
|
||||
let sender = RecordedDesktopWakeSender()
|
||||
app.hostLibrary.wakeOnLAN = WakeOnLANController(sender: sender)
|
||||
app.hostLibrary.hostAccess.entitlement = .loading
|
||||
var continuation: CheckedContinuation<Void, Never>?
|
||||
app.hostLibrary.resolveInitialHostEntitlement = {
|
||||
await withCheckedContinuation { continuation = $0 }
|
||||
app.hostLibrary.hostAccess.entitlement = status
|
||||
}
|
||||
defer { app.hostLibrary.resolveInitialHostEntitlement = nil }
|
||||
var configured = false
|
||||
let target = hosts[3]
|
||||
let operation = Task { await app.requestWake(target) { configured = true } }
|
||||
for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) }
|
||||
#expect(continuation != nil)
|
||||
#expect(sender.count == 0)
|
||||
#expect(!pro.isPaywallPresented)
|
||||
continuation?.resume()
|
||||
let notice = await operation.value
|
||||
#expect(!configured)
|
||||
#expect(pro.isPaywallPresented == (status == .free))
|
||||
#expect(sender.count == (status.hasProAccess ? 1 : 0))
|
||||
if status.hasProAccess {
|
||||
#expect(notice == String(localized: AppStrings.LocalNetwork.sent))
|
||||
} else if status == .verificationFailed {
|
||||
#expect(notice == HostAccessError.entitlementVerificationFailed.localizedDescription)
|
||||
} else {
|
||||
#expect(notice == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["", "02:11:22:33:44:55"])
|
||||
func lockedManualWakePresentsPaywallWithoutConfigurationOrPacket(macAddress: String) {
|
||||
func lockedManualWakePresentsPaywallWithoutConfigurationOrPacket(macAddress: String) async {
|
||||
var hosts = (0..<4).map {
|
||||
Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0)))
|
||||
}
|
||||
@@ -23,7 +62,8 @@ struct WakeOnLANAccessTests {
|
||||
defer { app.shutdownForTermination() }
|
||||
app.hostLibrary.wakeOnLAN = WakeOnLANController(sender: UnexpectedWakeSender())
|
||||
var configured = false
|
||||
#expect(app.requestWake(hosts[3]) { configured = true } == nil)
|
||||
let notice = await app.requestWake(hosts[3]) { configured = true }
|
||||
#expect(notice == nil)
|
||||
#expect(pro.isPaywallPresented)
|
||||
#expect(!configured)
|
||||
}
|
||||
@@ -39,19 +79,21 @@ struct WakeOnLANAccessTests {
|
||||
#expect(!pro.isPaywallPresented)
|
||||
}
|
||||
|
||||
@Test func freeManualWakeOpensConfigurationWithoutPaywall() {
|
||||
@Test func freeManualWakeOpensConfigurationWithoutPaywall() async {
|
||||
let pro = ProAccessController.testing(status: .free)
|
||||
let app = AppModel(hosts: HostLibraryModel(), proAccess: pro)
|
||||
var configured = false
|
||||
#expect(app.requestWake(Host(name: "Host", host: "server.invalid")) { configured = true } == nil)
|
||||
let notice = await app.requestWake(Host(name: "Host", host: "server.invalid")) { configured = true }
|
||||
#expect(notice == nil)
|
||||
#expect(!pro.isPaywallPresented)
|
||||
#expect(configured)
|
||||
}
|
||||
|
||||
@Test func proWakeWithoutMACOpensConfigurationRatherThanSending() {
|
||||
@Test func proWakeWithoutMACOpensConfigurationRatherThanSending() async {
|
||||
let app = AppModel(hosts: HostLibraryModel())
|
||||
var configured = false
|
||||
#expect(app.requestWake(Host(name: "Host", host: "server.invalid")) { configured = true } == nil)
|
||||
let notice = await app.requestWake(Host(name: "Host", host: "server.invalid")) { configured = true }
|
||||
#expect(notice == nil)
|
||||
#expect(configured)
|
||||
#expect(!app.proAccess.isPaywallPresented)
|
||||
}
|
||||
@@ -90,3 +132,10 @@ struct WakeOnLANAccessTests {
|
||||
private nonisolated struct UnexpectedWakeSender: WakeOnLANSending {
|
||||
func send(to address: MACAddress) throws { Issue.record("A locked host must not send a Wake packet") }
|
||||
}
|
||||
|
||||
private nonisolated final class RecordedDesktopWakeSender: WakeOnLANSending, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var packets = 0
|
||||
var count: Int { lock.withLock { packets } }
|
||||
func send(to address: MACAddress) { lock.withLock { packets += 1 } }
|
||||
}
|
||||
Reference in new issue
Block a user