Files
Termind/TFMTests/RepositoryOutboxTests.swift
T
Kaede Akino ad2bf28790 refactor: remove obsolete application paths and adapters
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.
2026-10-04 22:10:09 +08:00

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]
)
}
}