Files
Termind/FrameworkTests/HistorySyncTests.swift

395 lines
20 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import CloudKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@testable import TermindInfrastructure
@Suite @MainActor
struct HistorySyncTests {
@Test func newCloudRecordsUseNativeFieldsAndOptionalRemovalRoundTrips() async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
try await fixture.start()
let event = try #require(fixture.record(fixture.event.key))
#expect(event.recordType == "ConnectionHistoryItem")
#expect(event["payload"] == nil)
#expect(event["payloadAsset"] == nil)
#expect(event["hostID"] as? String != nil)
#expect(event["connectedAt"] as? Date != nil)
#expect(try HistoryRecordSyncCoordinator.envelope(from: event) == fixture.event)
let device = try #require(fixture.record(fixture.device.key))
#expect(device.recordType == "DeviceItem")
#expect(device["displayName"] as? String == "Mac")
#expect(device["payload"] == nil)
#expect(try HistoryRecordSyncCoordinator.envelope(from: device) == fixture.device)
event["endedAt"] = NSDate()
try HistoryCloudRecordV2.write(fixture.event, into: event)
#expect(event["endedAt"] == nil)
event["connectionKind"] = "future-protocol" as NSString
#expect(throws: (any Error).self) { try HistoryRecordSyncCoordinator.envelope(from: event) }
}
@Test func lateCancellationDoesNotProduceFailure() async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
try await fixture.start()
let previous = fixture.coordinator.status
fixture.coordinator.lifecycle.failClosed(CancellationError())
#expect(fixture.coordinator.status == previous)
#expect(fixture.coordinator.lifecycle.isReadyToSend)
fixture.coordinator.pause()
fixture.coordinator.lifecycle.failClosed(CKError(.operationCancelled))
#expect(fixture.coordinator.status == .paused)
}
@Test func syncFailuresUseApplicationLoggingWithoutRecordPayloads() async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let logging = LaunchFileLogService(directory: directory, defaults: fixture.defaults)
try logging.setSaving(true)
fixture.coordinator.configureLogging(logging)
try await fixture.start()
fixture.coordinator.lifecycle.failClosed(
NSError(
domain: "test-sync", code: 42,
userInfo: [NSLocalizedDescriptionKey: "private-host.example"]))
let file = try #require(logging.files().first)
let content = try String(contentsOf: logging.exportURL(for: file.id), encoding: .utf8)
#expect(content.contains("cloud.sync.failed"))
#expect(content.contains("stage=lifecycle"))
#expect(content.contains("zone=TermindConnectionHistoryV1"))
#expect(content.contains("error-code=42"))
#expect(!content.contains("private-host.example"))
}
@Test func deviceRemainsEligibleWithHistoryAndVaultDisabled() async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
fixture.defaults.set(false, forKey: SettingsKey.vaultSyncEnabled)
fixture.defaults.set(false, forKey: SettingsKey.historySyncEnabled)
try await fixture.start()
#expect(fixture.connectionSync.status == .paused)
#expect(fixture.record(fixture.event.key) == nil)
#expect(fixture.record(fixture.device.key) != nil)
fixture.coordinator.setEnabled(true)
await fixture.coordinator.lifecycle.operation?.value
#expect(fixture.record(fixture.event.key) != nil)
#expect(fixture.destination.serializations.allSatisfy { $0 == nil })
fixture.defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled)
fixture.coordinator.start()
fixture.deviceSync.start()
#expect(fixture.record(fixture.device.key) == nil)
}
@Test func deletedEventsCannotBeRevivedByLaterUpdates() async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
try await fixture.start()
var deleted = fixture.event
deleted.isDeleted = true
deleted.payload = nil
deleted.version.counter += 1
_ = try fixture.persistence.applyRemote([deleted])
var delayed = fixture.event
delayed.version.counter += 100
_ = try fixture.persistence.applyRemote([delayed])
#expect(try fixture.persistence.journal.envelope(for: delayed.key)?.isDeleted == true)
#expect(try fixture.context.fetchCount(FetchDescriptor<PersistentConnectionHistoryEntry>()) == 0)
#expect(try fixture.context.fetchCount(FetchDescriptor<PersistentDeviceRecord>()) == 1)
#expect(try fixture.persistence.journal.pendingEnvelopes().contains { $0.key == deleted.key })
}
@Test func retentionTombstoneCreatedDuringFetchRemainsPending() async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
try await fixture.start()
for index in 0..<ConnectionHistoryModel.limit {
fixture.context.insert(
PersistentConnectionHistoryEntry(
ConnectionHistoryEntry(
hostID: UUID(), kind: .ssh,
connectedAt: Date(timeIntervalSince1970: TimeInterval(index + 1)))))
}
try fixture.context.save()
let entry = ConnectionHistoryEntry(
hostID: UUID(), kind: .ssh, connectedAt: Date(timeIntervalSince1970: 0))
let envelope = HistoryEnvelope(
key: HistoryRecordKey(kind: .connectionHistory, id: entry.id),
version: VaultVersion(modifiedAt: Date(timeIntervalSince1970: 200), counter: 1, writerID: UUID()),
payload: .connectionHistory(entry))
let record = try HistoryRecordSyncCoordinator.makeRecord(
.init(envelope: envelope),
recordID: .init(recordName: envelope.key.recordName, zoneID: fixture.coordinator.lifecycle.zoneID))
try fixture.coordinator.receiveRemoteRecords([record])
let retainedEnvelope = try fixture.persistence.journal.envelope(for: envelope.key)
let retained = try #require(retainedEnvelope)
#expect(retained.isDeleted)
#expect(retained.version > envelope.version)
#expect(try fixture.persistence.journal.pendingEnvelopes().contains { $0.key == envelope.key })
#expect(fixture.record(envelope.key) == nil)
#expect(
fixture.destination.nextEngine.deleteRecordNames(in: fixture.coordinator.lifecycle.zoneID)
.contains(envelope.key.recordName))
}
@Test func historyIgnoresRecordsAndRestrictionsFromRetiredVaultImporter() async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
var retiredScope = fixture.scope
retiredScope.zone = VaultSyncCoordinator.zoneName
retiredScope.consumer = "history-legacy-import"
try CloudSyncSafetyStore(context: fixture.context, scope: retiredScope).quarantine(
recordName: fixture.event.key.recordName, payloadData: Data([9]),
reason: "Retired source", reasonCode: .unsupportedFormat)
try fixture.context.save()
try await fixture.start()
let record = try #require(fixture.record(fixture.event.key))
let oldRecord = CKRecord(
recordType: "VaultItem",
recordID: .init(
recordName: record.recordID.recordName,
zoneID: .init(zoneName: VaultSyncCoordinator.zoneName, ownerName: CKCurrentUserDefaultName)))
try fixture.coordinator.receiveRemoteRecords([oldRecord])
#expect(fixture.record(fixture.event.key) != nil)
#expect(fixture.record(fixture.device.key) != nil)
#expect(!TermindSchemaV36.models.contains { $0 == PersistentHistoryImportState.self })
#expect(try fixture.coordinator.lifecycle.store.safety.blockedRecordNames(fixture.scope).isEmpty)
#expect(try fixture.persistence.journal.envelope(for: fixture.event.key) == fixture.event)
}
@Test func initialHistoryFetchFailurePreventsUploadsAndRetryRecovers() async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
fixture.destination.nextEngine.fetchBody = { throw CocoaError(.fileReadUnknown) }
fixture.coordinator.start()
await fixture.coordinator.lifecycle.operation?.value
#expect(fixture.record(fixture.event.key) == nil)
#expect(fixture.destination.nextEngine.sendCount == 0)
fixture.destination.nextEngine = LifecycleEngine()
fixture.coordinator.retry()
await fixture.coordinator.lifecycle.operation?.value
#expect(fixture.record(fixture.event.key) != nil)
}
@Test func historyPreferencesDoNotInheritRetiredVaultSelectionOrEnvironment() throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
fixture.defaults.removeObject(forKey: SettingsKey.historySyncEnabled)
fixture.defaults.set(false, forKey: SettingsKey.vaultSyncEnabled)
fixture.defaults.set("Retired", forKey: SettingsKey.vaultSyncEnvironment)
SettingsKey.migrateSyncPreferences(in: fixture.defaults)
#expect(fixture.defaults.object(forKey: SettingsKey.historySyncEnabled) == nil)
#expect(fixture.defaults.object(forKey: SettingsKey.historySyncEnvironment) == nil)
fixture.defaults.set(false, forKey: SettingsKey.historySyncEnabled)
SettingsKey.migrateSyncPreferences(in: fixture.defaults)
#expect(!fixture.defaults.bool(forKey: SettingsKey.historySyncEnabled))
}
@Test func coordinatorsKeepSeparateEnginesJournalsAndRestrictionsInTheDeployedZone() async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
try await fixture.start()
#expect(ConnectionHistorySyncCoordinator.zoneName == "TermindConnectionHistoryV1")
#expect(DeviceSyncCoordinator.zoneName == ConnectionHistorySyncCoordinator.zoneName)
#expect(DeviceSyncCoordinator.subscriptionID != ConnectionHistorySyncCoordinator.subscriptionID)
let historyScope = try #require(fixture.coordinator.lifecycle.scope)
let deviceScope = try #require(fixture.deviceSync.core.lifecycle.scope)
#expect(historyScope != deviceScope)
let historyState = try HistorySyncJournal(context: fixture.context, kind: .connectionHistory).state()
let deviceState = try HistorySyncJournal(context: fixture.context, kind: .device).state()
#expect(historyState.key != deviceState.key)
historyState.engineStateData = Data([1])
deviceState.engineStateData = Data([2])
try fixture.context.save()
let futureDevice = try #require(fixture.record(fixture.device.key))
futureDevice["formatVersion"] = NSNumber(value: 99)
try fixture.deviceSync.core.receiveRemoteRecords([futureDevice])
#expect(fixture.record(fixture.device.key) == nil)
#expect(fixture.record(fixture.event.key) != nil)
fixture.deviceSync.core.lifecycle.failClosed(CocoaError(.fileReadUnknown))
#expect(fixture.coordinator.lifecycle.isReadyToSend)
try HistorySyncJournal(context: fixture.context, kind: .device).resetForZone()
#expect(historyState.engineStateData == Data([1]))
#expect(deviceState.engineStateData == nil)
fixture.connectionSync.setEnabled(false)
#expect(fixture.connectionSync.status == .paused)
}
@Test func freshInstallReadsExistingCloudRecordsWithHistoryArrivingBeforeDevice() async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
try await fixture.start()
let deviceID = UUID()
let entry = ConnectionHistoryEntry(hostID: UUID(), kind: .ssh, sourceDeviceID: deviceID)
let version = VaultVersion.baseline(writerID: UUID())
let event = HistoryEnvelope(
key: .init(kind: .connectionHistory, id: entry.id), version: version,
payload: .connectionHistory(entry))
let device = HistoryEnvelope(
key: .init(kind: .device, id: deviceID), version: version,
payload: .device(DeviceRecord(id: deviceID, displayName: "Retired Mac")))
let devices = DeviceDirectoryModel(
repository: SwiftDataDeviceDirectoryRepository(context: fixture.context),
currentDeviceID: UUID(), currentDeviceName: "New Mac")
fixture.deviceSync.didApplyRemote = { _ in devices.reload() }
let zone = CKRecordZone.ID(zoneName: "TermindConnectionHistoryV1", ownerName: CKCurrentUserDefaultName)
let eventRecord = try HistoryRecordSyncCoordinator.makeRecord(
.init(envelope: event),
recordID: .init(recordName: event.key.recordName, zoneID: zone))
let deviceRecord = try HistoryRecordSyncCoordinator.makeRecord(
.init(envelope: device),
recordID: .init(recordName: device.key.recordName, zoneID: zone))
try fixture.coordinator.receiveRemoteRecords([eventRecord])
#expect(try fixture.persistence.journal.envelope(for: event.key) == event)
#expect(devices.name(for: deviceID) == nil)
try fixture.deviceSync.core.receiveRemoteRecords([deviceRecord])
#expect(devices.name(for: deviceID) == "Retired Mac")
#expect(try fixture.persistence.journal.envelope(for: event.key) == event)
// Neither owner consumes the other category even though CloudKit fetches the same zone.
try fixture.coordinator.receiveRemoteRecords([deviceRecord])
try fixture.deviceSync.core.receiveRemoteRecords([eventRecord])
#expect(try fixture.persistence.journal.envelope(for: event.key) == event)
}
@Test(arguments: [false, true])
func missingDeviceProjectionIsRestoredWithoutChangingItsJournalVersion(replay: Bool) async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
try await fixture.start()
let record = try #require(fixture.record(fixture.device.key))
for row in try fixture.context.fetch(FetchDescriptor<PersistentDeviceRecord>()) {
fixture.context.delete(row)
}
try fixture.context.save()
if replay {
try fixture.deviceSync.core.receiveRemoteRecords([record])
} else {
try fixture.persistence.prepare()
}
#expect(
try fixture.context.fetch(FetchDescriptor<PersistentDeviceRecord>()).map(\.value)
== [DeviceRecord(id: fixture.device.key.id, displayName: "Mac")])
#expect(try fixture.persistence.journal.envelope(for: fixture.device.key) == fixture.device)
}
@Test func localHistoryJournalKeepsTheShippedV1Shape() throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
let event = fixture.event
let fixtureJSON = """
{"formatVersion":1,"key":{"kind":"connectionHistory","id":"\(event.key.id)"},
"version":{"modifiedAt":-978307100,"counter":1,"writerID":"\(event.version.writerID)"},
"isDeleted":true}
"""
let decoded = try HistorySyncCodec.decode(Data(fixtureJSON.utf8))
#expect(decoded.key == event.key)
#expect(decoded.isDeleted)
#expect(try HistorySyncCodec.decode(HistorySyncCodec.encode(event)) == event)
#expect(try HistorySyncCodec.decode(HistorySyncCodec.encode(fixture.device)) == fixture.device)
}
@Test func bootstrapRollsBackAndIgnoresRetiredVaultJournal() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let context = container.mainContext
let device = DeviceRecord(id: UUID(), displayName: "Local")
let payload = VaultRecordPayload.device(device)
let legacy = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
let row = try PersistentVaultSyncEntry(envelope: legacy, needsUpload: true)
row.systemFieldsData = Data([1, 2, 3])
context.insert(row)
context.insert(PersistentDeviceRecord(device))
try context.save()
let persistence = HistorySyncPersistence(context: context)
#expect(throws: (any Error).self) {
_ = try persistence.applyRemote([], beforeSave: { throw CocoaError(.fileWriteUnknown) })
}
#expect(try context.fetchCount(FetchDescriptor<PersistentHistorySyncEntry>()) == 0)
try persistence.prepare()
try persistence.prepare()
let created = try #require(context.fetch(FetchDescriptor<PersistentHistorySyncEntry>()).first)
#expect(created.systemFieldsData == nil)
#expect(
created.envelope?.version
== .baseline(writerID: try HistorySyncJournal(context: context, kind: .device).state().installationID))
#expect(created.envelope?.version != legacy.version)
#expect(created.needsUpload)
#expect(try context.fetchCount(FetchDescriptor<PersistentHistorySyncEntry>()) == 1)
}
}
@MainActor
private final class HistoryFixture {
let container: ModelContainer
var context: ModelContext { container.mainContext }
let defaults: UserDefaults
let suite = "HistorySyncTests.\(UUID())"
let destination = LifecycleNetwork()
let persistence: HistorySyncPersistence
let connectionSync: ConnectionHistorySyncCoordinator
let deviceSync: DeviceSyncCoordinator
var coordinator: HistoryRecordSyncCoordinator { connectionSync.core }
let deviceNetwork = LifecycleNetwork()
let event: HistoryEnvelope
let device: HistoryEnvelope
var scope: CloudSyncScope {
CloudSyncScope(
container: CloudSyncContainer.identifier, environment: "Test", account: "current-user",
zone: HistoryRecordSyncCoordinator.zoneName, owner: CKCurrentUserDefaultName)
}
init() throws {
container = try TermindPersistence.makeContainer(inMemory: true)
defaults = UserDefaults(suiteName: suite)!
defaults.set(true, forKey: SettingsKey.iCloudSyncEnabled)
defaults.set(true, forKey: SettingsKey.historySyncEnabled)
persistence = HistorySyncPersistence(context: container.mainContext)
let devicePayload = HistoryRecordPayload.device(DeviceRecord(id: UUID(), displayName: "Mac"))
let eventPayload = HistoryRecordPayload.connectionHistory(ConnectionHistoryEntry(hostID: UUID(), kind: .ssh))
let version = VaultVersion(modifiedAt: Date(timeIntervalSince1970: 100), counter: 1, writerID: UUID())
event = HistoryEnvelope(key: eventPayload.key, version: version, payload: eventPayload)
device = HistoryEnvelope(key: devicePayload.key, version: version, payload: devicePayload)
_ = try persistence.applyRemote([event, device])
try persistence.journal.markNeedsUpload([event.key, device.key])
try container.mainContext.save()
let destination = destination
connectionSync = ConnectionHistorySyncCoordinator(
persistence: persistence, defaults: defaults, environmentIdentifier: "Test",
makeTransport: { _, _ in destination.transport })
let deviceNetwork = deviceNetwork
deviceSync = DeviceSyncCoordinator(
persistence: persistence, defaults: defaults, environmentIdentifier: "Test",
makeTransport: { _, _ in deviceNetwork.transport })
}
func start() async throws {
connectionSync.start()
deviceSync.start()
await coordinator.lifecycle.operation?.value
await deviceSync.core.lifecycle.operation?.value
#expect(coordinator.lifecycle.isReadyToSend == connectionSync.isEnabled)
#expect(deviceSync.core.lifecycle.isReadyToSend)
}
func stop() {
coordinator.pause()
deviceSync.pause()
defaults.removePersistentDomain(forName: suite)
}
func record(_ key: HistoryRecordKey) -> CKRecord? {
let owner = key.kind == .device ? deviceSync.core : coordinator
return owner.recordToSave(
CKRecord.ID(recordName: key.recordName, zoneID: owner.lifecycle.zoneID),
engine: key.kind == .device ? deviceNetwork.nextEngine : destination.nextEngine)
}
}