Files
Termind/TFMTests/RepositoryOutboxTests.swift

291 lines
13 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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]
)
}
}