425 lines
20 KiB
Swift
425 lines
20 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPersistence
|
|
import Testing
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
@Suite @MainActor
|
|
struct HostAccessTests {
|
|
private func fixtures(_ count: Int) -> [Host] {
|
|
(0..<count).map {
|
|
Host(
|
|
name: "Host \($0)", host: "host\($0).invalid",
|
|
createdAt: Date(timeIntervalSince1970: Double($0)))
|
|
}
|
|
}
|
|
|
|
@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
|
|
var continuation: CheckedContinuation<Void, Never>?
|
|
library.resolveInitialHostEntitlement = {
|
|
await withCheckedContinuation { continuation = $0 }
|
|
library.hostAccess.entitlement = .purchased(.purchased)
|
|
}
|
|
let added = Host(name: "New", host: "new.invalid")
|
|
let operation = Task {
|
|
try await library.resolveSavingHostsAccess([added])
|
|
return library.saveHost(added)
|
|
}
|
|
defer { library.resolveInitialHostEntitlement = nil }
|
|
for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) }
|
|
#expect(continuation != nil)
|
|
#expect(library.hosts.count == 3)
|
|
#expect(library.host(added.id) == nil)
|
|
continuation?.resume()
|
|
#expect(try await operation.value)
|
|
#expect(library.host(added.id) != nil)
|
|
}
|
|
|
|
@Test func onlyExcessOperationsWaitForInitialEntitlement() async throws {
|
|
let hosts = fixtures(4)
|
|
let library = HostLibraryModel(hosts: hosts)
|
|
library.hostAccess.entitlement = .loading
|
|
var refreshes = 0
|
|
library.resolveInitialHostEntitlement = {
|
|
refreshes += 1
|
|
library.hostAccess.entitlement = .purchased(.purchased)
|
|
}
|
|
defer { library.resolveInitialHostEntitlement = nil }
|
|
try await library.resolveHostAccess(hosts[0])
|
|
try await library.resolveSavingHostsAccess([hosts[3]])
|
|
#expect(refreshes == 0)
|
|
try await library.resolveHostAccess(hosts[3])
|
|
#expect(refreshes == 1)
|
|
try library.requireHostAccess(hosts[3])
|
|
}
|
|
|
|
@Test(arguments: [EntitlementStatus.free, .verificationFailed])
|
|
func resolvedFreeAndFailedVerificationDoNotWriteAndOnlyFreePresentsUpgrade(status: EntitlementStatus) async {
|
|
let library = HostLibraryModel(hosts: fixtures(3))
|
|
library.hostAccess.entitlement = .loading
|
|
var upgrades = 0
|
|
library.requestHostUpgrade = { upgrades += 1 }
|
|
library.resolveInitialHostEntitlement = { library.hostAccess.entitlement = status }
|
|
defer { library.resolveInitialHostEntitlement = nil }
|
|
let added = Host(name: "New", host: "new.invalid")
|
|
do {
|
|
try await library.resolveSavingHostsAccess([added])
|
|
Issue.record("A non-Pro entitlement cannot admit another host")
|
|
} catch {
|
|
#expect((error as? HostAccessError) == (status == .free ? .hostLimit : .entitlementVerificationFailed))
|
|
}
|
|
#expect(library.hosts.count == 3)
|
|
#expect(upgrades == (status == .free ? 1 : 0))
|
|
}
|
|
|
|
@Test func creationOrderIgnoresDisplayOrderAndPromotesAfterDeletion() {
|
|
var hosts = fixtures(5)
|
|
hosts[0].sortIndex = 99
|
|
hosts[0].name = "Z"
|
|
let policy = HostAccessPolicy(entitlement: .free)
|
|
#expect(policy.unlockedIDs(in: Array(hosts.reversed())) == Set(hosts.prefix(3).map(\.id)))
|
|
#expect(policy.isLocked(hosts[3].id, in: hosts))
|
|
hosts.removeFirst()
|
|
#expect(!policy.isLocked(hosts[2].id, in: hosts))
|
|
}
|
|
|
|
@Test func legacyOrderingIsStableAndPrecedesNewHosts() {
|
|
var hosts = fixtures(5)
|
|
for index in 0..<4 { hosts[index].createdAt = nil }
|
|
let expected = Set(hosts.prefix(4).sorted { $0.id.uuidString < $1.id.uuidString }.prefix(3).map(\.id))
|
|
let policy = HostAccessPolicy(entitlement: .free)
|
|
#expect(policy.unlockedIDs(in: hosts) == expected)
|
|
#expect(policy.unlockedIDs(in: Array(hosts.reversed())) == expected)
|
|
}
|
|
|
|
@Test func savedQuotaCountsDistinctAdditionsAndAllowsOverQuotaEditing() throws {
|
|
let hosts = fixtures(4)
|
|
let policy = HostAccessPolicy(entitlement: .free)
|
|
try policy.requireSaving([hosts[0], hosts[0]], in: hosts)
|
|
try policy.requireSaving([hosts[2], hosts[2]], in: Array(hosts.prefix(2)))
|
|
#expect(throws: HostAccessError.hostLimit) {
|
|
try policy.requireSaving([hosts[2], hosts[3]], in: Array(hosts.prefix(2)))
|
|
}
|
|
try HostAccessPolicy(entitlement: .purchased(.purchased)).requireSaving(hosts, in: [])
|
|
}
|
|
|
|
@Test func booleanSavePresentsQuotaWithoutAStorageError() {
|
|
let library = HostLibraryModel(hosts: fixtures(3))
|
|
library.hostAccess.entitlement = .free
|
|
var upgrades = 0
|
|
library.requestHostUpgrade = { upgrades += 1 }
|
|
let added = Host(name: "New", host: "new.invalid")
|
|
#expect(!library.saveHost(added))
|
|
#expect(library.storageError == nil)
|
|
#expect(library.host(added.id) == nil)
|
|
#expect(upgrades == 1)
|
|
}
|
|
|
|
@Test func unresolvedEntitlementsDoNotShowLocksButPreventExcessOperations() {
|
|
let hosts = fixtures(4)
|
|
for status in [EntitlementStatus.loading, .verificationFailed] {
|
|
let policy = HostAccessPolicy(entitlement: status)
|
|
#expect(!policy.isLocked(hosts[3].id, in: hosts))
|
|
#expect(throws: HostAccessError.self) { try policy.requireAccess(to: hosts[3].id, in: hosts) }
|
|
}
|
|
}
|
|
|
|
@Test func writesAreRejectedBeforeCustomIdentityOrImportSideEffects() throws {
|
|
let library = HostLibraryModel(hosts: fixtures(3))
|
|
library.hostAccess.entitlement = .free
|
|
var upgrades = 0
|
|
library.requestHostUpgrade = { upgrades += 1 }
|
|
var host = Host(name: "New", host: "new.invalid")
|
|
var draft = HostCustomIdentityDraft()
|
|
draft.sshUsername = "root"
|
|
draft.sshPassword = "not-persisted"
|
|
#expect(throws: HostAccessError.hostLimit) { try draft.save(into: &host, using: library) }
|
|
#expect(library.identities.isEmpty)
|
|
var batch = SSHImportBatch()
|
|
batch.hosts = [host]
|
|
batch.identities = [Identity(name: "Imported", username: "root")]
|
|
#expect(throws: HostAccessError.hostLimit) { try library.commitSSHImport(batch) }
|
|
#expect(library.hosts.count == 3)
|
|
#expect(library.identities.isEmpty)
|
|
#expect(upgrades == 2)
|
|
}
|
|
|
|
@Test func cachedAdmissionChecksTrackExpiryAndJumpHostLocks() throws {
|
|
let hosts = fixtures(4)
|
|
let library = HostLibraryModel(hosts: hosts)
|
|
let check = try #require(library.connectionAccessCheck(for: [hosts[3].id]))
|
|
try check()
|
|
library.hostAccess.entitlement = .free
|
|
#expect(throws: HostAccessError.lockedHost(hosts[3].id)) { try check() }
|
|
var target = hosts[0]
|
|
target.jumpHostID = hosts[3].id
|
|
#expect(throws: HostAccessError.lockedHost(hosts[3].id)) { try library.requireHostAccess(target) }
|
|
library.hostAccess.entitlement = .purchased(.purchased)
|
|
try check()
|
|
}
|
|
|
|
@Test func localCreationStampsOnceAndCopiesGetANewDate() throws {
|
|
let library = HostLibraryModel()
|
|
var host = Host(name: "New", host: "new.invalid")
|
|
#expect(library.saveHost(host))
|
|
let original = try #require(library.host(host.id)?.createdAt)
|
|
host.name = "Renamed"
|
|
#expect(library.saveHost(host))
|
|
#expect(library.host(host.id)?.createdAt == original)
|
|
var copy = try #require(library.host(host.id))
|
|
copy.id = UUID()
|
|
copy.createdAt = .distantPast
|
|
#expect(library.saveHost(copy))
|
|
#expect(library.host(copy.id)?.createdAt != .distantPast)
|
|
}
|
|
|
|
@Test func preparedAliasRouteRetainsSavedAdmissionAfterExpiry() async throws {
|
|
let hosts = fixtures(4)
|
|
let library = HostLibraryModel(hosts: hosts)
|
|
var alias = hosts[3]
|
|
alias.id = UUID()
|
|
let route = SSHConnectionRoute(hosts: [alias], sourceHostIDs: [alias.id: hosts[3].id])
|
|
let preparation = try library.prepareSSHConnection(for: route, submissions: [:])
|
|
let configuration = try await preparation.resolve(discoveryOnly: true)
|
|
#expect(configuration.accessHostIDs.contains(hosts[3].id))
|
|
try configuration.accessCheck?()
|
|
library.hostAccess.entitlement = .free
|
|
#expect(throws: HostAccessError.lockedHost(hosts[3].id)) { try configuration.accessCheck?() }
|
|
}
|
|
|
|
@Test func historicalStoreMigratesAndCreationDatesSurviveReopening() throws {
|
|
let host = Host(name: "Legacy", host: "legacy.invalid")
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let url = directory.appendingPathComponent("store")
|
|
do {
|
|
let schema = Schema(versionedSchema: TermindSchemaV57.self)
|
|
let old = try ModelContainer(
|
|
for: schema,
|
|
configurations: ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none))
|
|
old.mainContext.insert(TermindSchemaV56HostModels.PersistentHost(host))
|
|
try old.mainContext.save()
|
|
}
|
|
let created = fixtures(1)[0]
|
|
do {
|
|
let container = try TermindPersistence.makeContainer(url: url)
|
|
let store = HostPersistence(context: container.mainContext)
|
|
#expect(try store.load().hosts == [host])
|
|
_ = try store.save(.init(hosts: [host, created]))
|
|
}
|
|
let reopened = try TermindPersistence.makeContainer(url: url)
|
|
let saved = try HostPersistence(context: reopened.mainContext).load().hosts
|
|
#expect(saved.first(where: { $0.id == host.id })?.createdAt == nil)
|
|
#expect(saved.first(where: { $0.id == created.id })?.createdAt == created.createdAt)
|
|
}
|
|
|
|
@Test func hostWireRetainsDatesAndRejectsLossyOldEncoding() throws {
|
|
let host = fixtures(1)[0]
|
|
let payload = VaultRecordPayload.host(host)
|
|
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
|
|
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope)
|
|
var old = envelope
|
|
old.formatVersion = 7
|
|
#expect(throws: (any Error).self) { try VaultSyncCodec.encode(old) }
|
|
var legacy = host
|
|
legacy.createdAt = nil
|
|
old.payload = .host(legacy)
|
|
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(old)) == old)
|
|
var snapshot = VaultSnapshot(hosts: [host])
|
|
try snapshot.apply(old)
|
|
#expect(snapshot.hosts[0].createdAt == host.createdAt)
|
|
}
|
|
|
|
@Test func legacyRemoteUpdatesPreserveKnownDatesInBothProjectionAndJournal() throws {
|
|
let container = try TermindPersistence.makeContainer(inMemory: true)
|
|
let host = fixtures(1)[0]
|
|
container.mainContext.insert(PersistentHost(host))
|
|
try container.mainContext.save()
|
|
var legacy = host
|
|
legacy.createdAt = nil
|
|
legacy.name = "Updated by an older client"
|
|
let payload = VaultRecordPayload.host(legacy)
|
|
var envelope = VaultEnvelope(
|
|
key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
|
|
envelope.formatVersion = 7
|
|
let result = try HostPersistence(context: container.mainContext).applyRemote([envelope])
|
|
#expect(result.snapshot.hosts.first?.name == legacy.name)
|
|
#expect(result.snapshot.hosts.first?.createdAt == host.createdAt)
|
|
#expect(result.needsUpload.contains(envelope.key))
|
|
let pending = try VaultSyncJournal(context: container.mainContext).pendingEnvelopes()
|
|
guard case .host(let saved) = pending.first(where: { $0.key == envelope.key })?.payload else {
|
|
Issue.record("The repaired host date must be journaled for upload")
|
|
return
|
|
}
|
|
#expect(saved.createdAt == host.createdAt)
|
|
}
|
|
}
|