Files
Termind/TermindTests/RepositoryOutboxTests.swift
T

248 lines
11 KiB
Swift

import Foundation
import SwiftData
import Testing
@testable import Termind
@Suite
@MainActor
struct RepositoryOutboxTests {
@Test
func versionThreeStorageNamesMigrateToThisDevice() throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: "TermindV3Migration-\(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 identity = Identity(
name: "Legacy Identity", username: "legacy",
credentialRef: "termind.password.legacy", passwordStorage: .thisDevice
)
let key = SSHKeyRef(
label: "Legacy Key", publicKey: "ssh-ed25519 AAAA legacy",
fingerprint: "SHA256:legacy", credentialRef: "termind.key.legacy",
storage: .thisDevice
)
do {
let schema = Schema(versionedSchema: TermindSchemaV3.self)
let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(for: schema, configurations: [configuration])
let context = container.mainContext
context.insert(TermindSchemaV3Models.PersistentIdentity(identity))
let persistentKey = TermindSchemaV3Models.PersistentSSHKey(key)
persistentKey.storage = "local" + "Keychain"
context.insert(persistentKey)
try context.save()
}
let schema = Schema(versionedSchema: TermindSchemaV5.self)
let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(
for: schema, migrationPlan: TermindMigrationPlan.self,
configurations: [configuration]
)
let context = container.mainContext
let migratedIdentity = try #require(
context.fetch(FetchDescriptor<PersistentIdentity>()).first
)
let migratedKey = try #require(
context.fetch(FetchDescriptor<PersistentSSHKey>()).first
)
#expect(migratedIdentity.value == identity)
#expect(migratedIdentity.passwordStorage == PasswordStorage.thisDevice.rawValue)
#expect(migratedKey.value == key)
#expect(migratedKey.storage == KeyStorage.thisDevice.rawValue)
#expect(migratedKey.certificate.isEmpty)
}
@Test
func versionOneStoreMigratesToCurrentSchemaWithoutLosingData() throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: "TermindMigration-\(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: .thisDevice
)
let key = SSHKeyRef(
label: "Deployment", publicKey: "ssh-ed25519 AAAA migration",
fingerprint: "SHA256:migration", credentialRef: "key-migration",
storage: .thisDevice
)
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 schema = Schema(versionedSchema: TermindSchemaV1.self)
let configuration = ModelConfiguration(
"Migration", schema: schema, url: storeURL
)
let container = try ModelContainer(
for: schema, configurations: [configuration]
)
let context = container.mainContext
context.insert(PersistentHost(host))
context.insert(PersistentHostGroup(group))
context.insert(TermindSchemaV3Models.PersistentIdentity(identity))
let persistentKey = TermindSchemaV3Models.PersistentSSHKey(key)
persistentKey.storage = "local" + "Keychain"
context.insert(persistentKey)
context.insert(PersistentKnownHost(knownHost))
context.insert(PersistentSnippetPackage(package))
context.insert(PersistentSnippet(snippet))
context.insert(PersistentPortForwardingRule(rule))
try context.save()
}
let schema = Schema(versionedSchema: TermindSchemaV5.self)
let configuration = ModelConfiguration(
"Migration", schema: schema, url: storeURL
)
let container = try ModelContainer(
for: schema, migrationPlan: TermindMigrationPlan.self,
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]
)
#expect(try context.fetch(FetchDescriptor<PersistentOutboxJob>()).isEmpty)
#expect(try context.fetch(FetchDescriptor<PersistentPendingDeletion>()).isEmpty)
#expect(try context.fetch(FetchDescriptor<PersistentDatabaseMetadata>()).isEmpty)
#expect(try context.fetch(FetchDescriptor<PersistentCommandHistoryEntry>()).isEmpty)
}
@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)
context.insert(PersistentHost(doomed))
context.insert(PersistentHost(dependent))
context.insert(PersistentSnippet(snippet))
context.insert(PersistentPortForwardingRule(rule))
context.insert(
PersistentCommandHistoryEntry(
CommandHistoryEntry(hostID: doomed.id, command: "uptime")
)
)
try context.save()
let repository = SwiftDataHostRepository(context: context)
#expect(
try repository.deletionImpact(for: doomed.id)
== HostDeletionImpact(
jumpHostReferences: 1, snippetReferences: 1, forwardingRules: 1
)
)
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<PersistentCommandHistoryEntry>()).isEmpty)
#expect(try context.fetch(FetchDescriptor<PersistentPendingDeletion>()).isEmpty)
}
@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)
}
private func makeContainer(_ name: String) throws -> ModelContainer {
let configuration = ModelConfiguration(
name, schema: HostPersistence.schema, isStoredInMemoryOnly: true
)
return try ModelContainer(
for: HostPersistence.schema, configurations: [configuration]
)
}
}