Remove the transfer forwarding adapter, borrowed SFTP channel scheduling, legacy assistant send entry points, unreachable Telnet password state, unused Tailscale APIs, and unused host deletion impact projections. Migrate behavioral tests to current paths and cover SFTP passphrase saving. Validation: Swift formatting and shared-copy/localization checks passed. The macOS test run reported failures in ConnectionStatusIconLayoutTests and AgentRuntimeTests; investigation and iOS validation remain pending.
288 lines
13 KiB
Swift
288 lines
13 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindInfrastructure
|
|
import TermindPersistence
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
import Testing
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
@testable import Termind
|
|
|
|
@Suite
|
|
@MainActor
|
|
struct RepositoryOutboxTests {
|
|
@Test
|
|
func freshBaselineStoreReopensWithoutLosingVaultData() throws {
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appending(path: "TermindBaseline-\(UUID().uuidString)", directoryHint: .isDirectory)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let storeURL = directory.appending(path: "Termind.store")
|
|
|
|
let host = Host(name: "Production", host: "prod.example")
|
|
let group = HostGroup(name: "Servers")
|
|
let identity = Identity(
|
|
name: "Operator", username: "deploy", passwordStorage: .iCloud,
|
|
colorTag: .orange
|
|
)
|
|
let key = SSHKeyRef(
|
|
label: "Deployment", publicKey: "ssh-ed25519 AAAA baseline",
|
|
fingerprint: "SHA256:baseline", credentialRef: "key-baseline",
|
|
storage: .iCloud, colorTag: .purple
|
|
)
|
|
let knownHost = KnownHostEntry(
|
|
hostname: "prod.example", algorithm: .ed25519,
|
|
fingerprint: "SHA256:known-host"
|
|
)
|
|
let package = SnippetPackage(name: "Operations")
|
|
let snippet = Snippet(
|
|
title: "Health", body: "uptime", packageID: package.id,
|
|
targetHostIDs: [host.id]
|
|
)
|
|
let rule = PortForwardingRule(hostID: host.id)
|
|
|
|
do {
|
|
let configuration = localModelConfiguration(
|
|
"Baseline", schema: HostPersistence.schema, url: storeURL
|
|
)
|
|
let container = try ModelContainer(
|
|
for: HostPersistence.schema, configurations: [configuration]
|
|
)
|
|
let context = container.mainContext
|
|
context.insert(PersistentHost(host))
|
|
context.insert(PersistentHostGroup(group))
|
|
context.insert(PersistentIdentity(identity))
|
|
context.insert(PersistentSSHKey(key))
|
|
context.insert(PersistentKnownHost(knownHost))
|
|
context.insert(PersistentSnippetPackage(package))
|
|
context.insert(PersistentSnippet(snippet))
|
|
context.insert(PersistentPortForwardingRule(rule))
|
|
try context.save()
|
|
}
|
|
|
|
let configuration = localModelConfiguration(
|
|
"Baseline", schema: HostPersistence.schema, url: storeURL
|
|
)
|
|
let container = try ModelContainer(
|
|
for: HostPersistence.schema, configurations: [configuration]
|
|
)
|
|
let context = container.mainContext
|
|
|
|
#expect(try context.fetch(FetchDescriptor<PersistentHost>()).map(\.value) == [host])
|
|
#expect(try context.fetch(FetchDescriptor<PersistentHostGroup>()).map(\.value) == [group])
|
|
#expect(try context.fetch(FetchDescriptor<PersistentIdentity>()).map(\.value) == [identity])
|
|
#expect(try context.fetch(FetchDescriptor<PersistentSSHKey>()).map(\.value) == [key])
|
|
#expect(
|
|
try context.fetch(FetchDescriptor<PersistentKnownHost>()).map(\.value) == [knownHost]
|
|
)
|
|
#expect(
|
|
try context.fetch(FetchDescriptor<PersistentSnippetPackage>()).map(\.value)
|
|
== [package]
|
|
)
|
|
#expect(try context.fetch(FetchDescriptor<PersistentSnippet>()).map(\.value) == [snippet])
|
|
#expect(
|
|
try context.fetch(FetchDescriptor<PersistentPortForwardingRule>()).map(\.value)
|
|
== [rule]
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func schemaV6MigratesKeysAndIdentitiesWithNoColor() throws {
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("CredentialColorMigration-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(
|
|
at: directory, withIntermediateDirectories: true
|
|
)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let storeURL = directory.appendingPathComponent("Termind.store")
|
|
let legacySchema = Schema(versionedSchema: TermindSchemaV6.self)
|
|
let identity = Identity(name: "Legacy", username: "legacy")
|
|
let key = SSHKeyRef(label: "Legacy Key", fingerprint: "SHA256:legacy")
|
|
|
|
var legacyContainer: ModelContainer? = try ModelContainer(
|
|
for: legacySchema,
|
|
configurations: [
|
|
localModelConfiguration(
|
|
"CredentialColorMigration", schema: legacySchema, url: storeURL
|
|
)
|
|
]
|
|
)
|
|
legacyContainer?.mainContext.insert(
|
|
TermindSchemaV6Models.PersistentIdentity(identity)
|
|
)
|
|
legacyContainer?.mainContext.insert(
|
|
TermindSchemaV6Models.PersistentSSHKey(key)
|
|
)
|
|
try legacyContainer?.mainContext.save()
|
|
legacyContainer = nil
|
|
|
|
let migrated = try ModelContainer(
|
|
for: HostPersistence.schema,
|
|
migrationPlan: TermindMigrationPlan.self,
|
|
configurations: [
|
|
localModelConfiguration(
|
|
"CredentialColorMigration", schema: HostPersistence.schema,
|
|
url: storeURL
|
|
)
|
|
]
|
|
)
|
|
|
|
let identities = try migrated.mainContext.fetch(FetchDescriptor<PersistentIdentity>())
|
|
let keys = try migrated.mainContext.fetch(FetchDescriptor<PersistentSSHKey>())
|
|
#expect(identities.map(\.value.colorTag) == [.none])
|
|
#expect(keys.map(\.value.colorTag) == [.none])
|
|
}
|
|
|
|
@Test
|
|
func invalidPersistedCredentialColorsFallBackToNone() throws {
|
|
let container = try makeContainer("InvalidCredentialColors")
|
|
let identity = PersistentIdentity(Identity(name: "Deploy", username: "deploy"))
|
|
let key = PersistentSSHKey(SSHKeyRef(label: "Deploy Key"))
|
|
identity.colorTag = "future-identity-color"
|
|
key.colorTag = "future-key-color"
|
|
container.mainContext.insert(identity)
|
|
container.mainContext.insert(key)
|
|
try container.mainContext.save()
|
|
|
|
#expect(identity.value.colorTag == .none)
|
|
#expect(key.value.colorTag == .none)
|
|
}
|
|
|
|
@Test
|
|
func repositoryPublishesMonotonicMutationSnapshots() async throws {
|
|
let container = try makeContainer("RepositoryStream")
|
|
let repository = SwiftDataHostRepository(context: container.mainContext)
|
|
var iterator = repository.changes().makeAsyncIterator()
|
|
let initial = try #require(await iterator.next())
|
|
let host = Host(name: "Build", host: "build.example")
|
|
|
|
try repository.upsertHost(host)
|
|
let mutation = try #require(await iterator.next())
|
|
|
|
#expect(mutation.revision > initial.revision)
|
|
#expect(mutation.reason == .mutation)
|
|
#expect(mutation.value.hosts.map(\.id) == [host.id])
|
|
}
|
|
|
|
@Test
|
|
func hostDeletionPersistsMarkerAndCompleteOutboxAtomically() throws {
|
|
let container = try makeContainer("DeletionOutbox")
|
|
let context = container.mainContext
|
|
let doomed = Host(name: "Jump", host: "jump.example")
|
|
var dependent = Host(name: "App", host: "app.example")
|
|
dependent.jumpHostID = doomed.id
|
|
var snippet = Snippet(title: "Deploy", body: "deploy")
|
|
snippet.targetHostIDs = [doomed.id, dependent.id]
|
|
let rule = PortForwardingRule(hostID: doomed.id)
|
|
let doomedSSH = Identity(
|
|
name: "Jump", username: "deploy", ownerHostID: doomed.id, customPurpose: .ssh)
|
|
let doomedProxy = Identity(
|
|
name: "Jump Proxy", username: "proxy", ownerHostID: doomed.id, customPurpose: .proxy)
|
|
let reusable = Identity(name: "Reusable", username: "shared")
|
|
let retainedCustom = Identity(
|
|
name: "App", username: "app", ownerHostID: dependent.id, customPurpose: .ssh)
|
|
context.insert(PersistentHost(doomed))
|
|
context.insert(PersistentHost(dependent))
|
|
for identity in [doomedSSH, doomedProxy, reusable, retainedCustom] {
|
|
context.insert(PersistentIdentity(identity))
|
|
}
|
|
context.insert(PersistentSnippet(snippet))
|
|
context.insert(PersistentPortForwardingRule(rule))
|
|
let connectionHistory = ConnectionHistoryEntry(hostID: doomed.id, kind: .ssh)
|
|
context.insert(PersistentConnectionHistoryEntry(connectionHistory))
|
|
try context.save()
|
|
let repository = SwiftDataHostRepository(context: context)
|
|
|
|
try repository.requestHostDeletion(doomed.id)
|
|
|
|
let hiddenSnapshot = try repository.snapshot()
|
|
#expect(!hiddenSnapshot.value.hosts.contains { $0.id == doomed.id })
|
|
let markers = try context.fetch(FetchDescriptor<PersistentPendingDeletion>())
|
|
let jobs = try context.fetch(
|
|
FetchDescriptor<PersistentOutboxJob>(sortBy: [SortDescriptor(\.handlerID)])
|
|
)
|
|
#expect(markers.map(\.entityID) == [doomed.id])
|
|
#expect(jobs.map(\.handlerID) == HostDeletionOutbox.handlerIDs)
|
|
#expect(Set(jobs.map(\.eventID)).count == 1)
|
|
#expect(jobs.allSatisfy { $0.status == OutboxJobStatus.pending.rawValue })
|
|
|
|
try repository.clearReferencesForDeletedHost(doomed.id)
|
|
try repository.finalizeHostDeletion(doomed.id)
|
|
|
|
let remainingHosts = try context.fetch(FetchDescriptor<PersistentHost>())
|
|
#expect(remainingHosts.map(\.id) == [dependent.id])
|
|
#expect(remainingHosts.first?.jumpHostID == nil)
|
|
#expect(
|
|
try context.fetch(FetchDescriptor<PersistentSnippet>()).first?.targetHostIDs
|
|
== [dependent.id]
|
|
)
|
|
#expect(try context.fetch(FetchDescriptor<PersistentPortForwardingRule>()).isEmpty)
|
|
#expect(try context.fetch(FetchDescriptor<PersistentConnectionHistoryEntry>()).isEmpty)
|
|
let connectionHistoryEnvelope = try #require(
|
|
try HistorySyncJournal(context: context).envelope(
|
|
for: HistoryRecordKey(kind: .connectionHistory, id: connectionHistory.id)))
|
|
#expect(connectionHistoryEnvelope.isDeleted)
|
|
#expect(try context.fetch(FetchDescriptor<PersistentPendingDeletion>()).isEmpty)
|
|
#expect(
|
|
Set(try context.fetch(FetchDescriptor<PersistentIdentity>()).map(\.id))
|
|
== [reusable.id, retainedCustom.id]
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func persistentOutboxResetsInterruptedWorkAndRetriesFailures() throws {
|
|
let container = try makeContainer("OutboxStates")
|
|
let store = SwiftDataOutboxStore(context: container.mainContext)
|
|
let job = OutboxJob(
|
|
handlerID: "test", eventType: "test.event", payload: Data(),
|
|
nextAttemptAt: Date(timeIntervalSince1970: 0)
|
|
)
|
|
try store.enqueue([job])
|
|
#expect(try store.nextReady(at: Date())?.id == job.id)
|
|
|
|
try store.markRunning(job.id)
|
|
#expect(try store.nextReady(at: Date()) == nil)
|
|
try store.resetInterruptedJobs()
|
|
#expect(try store.nextReady(at: Date())?.attemptCount == 1)
|
|
|
|
try store.markFailed(job.id, error: "offline", retryAt: nil)
|
|
#expect(try store.nextReady(at: Date()) == nil)
|
|
try store.retryFailedJob(job.id)
|
|
#expect(try store.nextReady(at: Date())?.lastError == nil)
|
|
try store.markSucceeded(job.id)
|
|
#expect(try store.nextReady(at: Date()) == nil)
|
|
}
|
|
|
|
@Test
|
|
func deletingHostPreservesItsIndependentKnownHostRecords() throws {
|
|
let container = try makeContainer("IndependentKnownHosts")
|
|
let context = container.mainContext
|
|
let host = Host(name: "Removed", host: "shared.example")
|
|
let remaining = Host(name: "Another Login", host: host.host)
|
|
let trust = KnownHostEntry(hostname: host.host, fingerprint: "SHA256:trusted")
|
|
context.insert(PersistentHost(host))
|
|
context.insert(PersistentHost(remaining))
|
|
context.insert(PersistentKnownHost(trust))
|
|
try context.save()
|
|
let repository = SwiftDataHostRepository(context: context)
|
|
try repository.requestHostDeletion(host.id)
|
|
try repository.clearReferencesForDeletedHost(host.id)
|
|
try repository.finalizeHostDeletion(host.id)
|
|
#expect(try repository.snapshot().value.hosts.map(\.id) == [remaining.id])
|
|
#expect(try context.fetch(FetchDescriptor<PersistentKnownHost>()).map(\.value) == [trust])
|
|
}
|
|
|
|
private func makeContainer(_ name: String) throws -> ModelContainer {
|
|
let configuration = localModelConfiguration(
|
|
name, schema: HostPersistence.schema, isStoredInMemoryOnly: true
|
|
)
|
|
return try ModelContainer(
|
|
for: HostPersistence.schema, configurations: [configuration]
|
|
)
|
|
}
|
|
}
|