Files
Termind/FrameworkTests/CloudSyncSafetyFixture.swift
T

245 lines
11 KiB
Swift

import CloudKit
import CryptoKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@testable import TermindInfrastructure
enum SyncSafetyCategory: CaseIterable, Sendable {
case vault, settings, metrics
}
@MainActor
private struct SyncSafetyAdapter {
var lifecycle: CloudSyncLifecycle
var recordNames: [String]
var receive: ([CKRecord], [CKRecord.ID]) throws -> Void
var conflict: (CKError) throws -> Void
var batch: ([CKSyncEngine.PendingRecordZoneChange]) -> CKSyncEngine.RecordZoneChangeBatch?
var record: (CKRecord.ID) -> CKRecord?
var pendingNames: () throws -> Set<String>
var markTombstone: () throws -> Void
var failingMerge: (CloudSyncSafetyStore) throws -> Void
}
/// Real journals, coordinators, record coding and batch providers; only CloudKit transport is fake.
@MainActor
final class CloudSyncSafetyFixture {
let container: ModelContainer
let defaults: UserDefaults
let defaultsName = "CloudSyncSafetyTests.\(UUID())"
let network = LifecycleNetwork()
private let adapter: SyncSafetyAdapter
var lifecycle: CloudSyncLifecycle { adapter.lifecycle }
var recordIDs: [CKRecord.ID] {
adapter.recordNames.map { CKRecord.ID(recordName: $0, zoneID: lifecycle.zoneID) }
}
var receive: ([CKRecord], [CKRecord.ID]) throws -> Void { adapter.receive }
var conflict: (CKError) throws -> Void { adapter.conflict }
var batch: ([CKSyncEngine.PendingRecordZoneChange]) -> CKSyncEngine.RecordZoneChangeBatch? { adapter.batch }
var record: (CKRecord.ID) -> CKRecord? { adapter.record }
var pendingNames: () throws -> Set<String> { adapter.pendingNames }
var markTombstone: () throws -> Void { adapter.markTombstone }
var failingMerge: (CloudSyncSafetyStore) throws -> Void { adapter.failingMerge }
init(category: SyncSafetyCategory, url: URL? = nil, environment: String = "Test") throws {
container = try TermindPersistence.makeContainer(inMemory: url == nil, url: url)
defaults = UserDefaults(suiteName: defaultsName)!
for key in [
SettingsKey.iCloudSyncEnabled, SettingsKey.vaultSyncEnabled,
SettingsKey.settingsSyncEnabled, SettingsKey.metricsSyncEnabled,
] { defaults.set(true, forKey: key) }
let context = container.mainContext
switch category {
case .vault:
adapter = try Self.vault(context, defaults: defaults, network: network, environment: environment)
case .settings:
adapter = try Self.settings(context, defaults: defaults, network: network, environment: environment)
case .metrics:
adapter = try Self.metrics(context, defaults: defaults, network: network, environment: environment)
}
try context.save()
}
private static var version: VaultVersion {
VaultVersion(
modifiedAt: Date(timeIntervalSince1970: 100), counter: 1,
writerID: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!)
}
private static func vault(
_ context: ModelContext, defaults: UserDefaults, network: LifecycleNetwork, environment: String
) throws -> SyncSafetyAdapter {
let journal = VaultSyncJournal(context: context)
let payloads = ["First", "Second"].map { VaultRecordPayload.hostGroup(HostGroup(name: $0)) }
let envelopes = payloads.map { VaultEnvelope(key: $0.key, version: version, payload: $0) }
for envelope in envelopes { try journal.upsert(envelope, needsUpload: true) }
let coordinator = VaultSyncCoordinator(
context: context, defaults: defaults, environmentIdentifier: environment,
makeTransport: { _, _ in network.transport })
return SyncSafetyAdapter(
lifecycle: coordinator.lifecycle, recordNames: envelopes.map(\.key.recordName),
receive: { try coordinator.receiveRemoteRecords($0, deletions: $1) },
conflict: coordinator.handleFailedSave,
batch: { coordinator.makeChangeBatch($0, engine: network.nextEngine) },
record: { coordinator.recordToSave($0, engine: network.nextEngine) },
pendingNames: { Set(try journal.pendingEnvelopes().map(\.key.recordName)) },
markTombstone: {
var envelope = envelopes[0]
envelope.isDeleted = true
envelope.payload = nil
envelope.version.counter += 1
try journal.upsert(envelope, needsUpload: true)
try context.save()
},
failingMerge: { safety in
var envelope = envelopes[0]
envelope.version.counter += 1
_ = try HostPersistence(context: context).applyRemote(
[envelope],
beforeSave: {
try safety.clearQuarantine(recordName: envelope.key.recordName)
throw CocoaError(.fileWriteUnknown)
})
})
}
private static func settings(
_ context: ModelContext, defaults: UserDefaults, network: LifecycleNetwork, environment: String
) throws -> SyncSafetyAdapter {
let persistence = SettingsSyncPersistence(context: context, defaults: defaults)
let journal = persistence.journal
let payloads: [SettingsRecordPayload] = [.sftp(SFTPPreferences()), .ssh(SSHPreferences())]
let envelopes = payloads.map { SettingsEnvelope(key: $0.key, version: version, payload: $0) }
for envelope in envelopes { try journal.upsert(envelope, needsUpload: true) }
let secrets = AISecretStore(context: context, keychain: InMemorySecretStore(), defaults: defaults)
let coordinator = SettingsSyncCoordinator(
persistence: persistence, secrets: secrets, defaults: defaults, environmentIdentifier: environment,
makeTransport: { _, _ in network.transport })
return SyncSafetyAdapter(
lifecycle: coordinator.lifecycle, recordNames: envelopes.map(\.key.recordName),
receive: { try coordinator.receiveRemoteRecords($0, deletions: $1) },
conflict: coordinator.handleFailedSave,
batch: { coordinator.makeChangeBatch($0, engine: network.nextEngine) },
record: { coordinator.recordToSave($0, engine: network.nextEngine) },
pendingNames: { Set(try journal.pendingEnvelopes().map(\.key.recordName)) },
markTombstone: {
var envelope = envelopes[0]
envelope.isDeleted = true
envelope.payload = nil
envelope.version.counter += 1
try journal.upsert(envelope, needsUpload: true)
try context.save()
},
failingMerge: { safety in
var envelope = envelopes[0]
envelope.version.counter += 1
_ = try persistence.applyRemote(
[envelope],
beforeSave: {
try safety.clearQuarantine(recordName: envelope.key.recordName)
throw CocoaError(.fileWriteUnknown)
})
})
}
private static func metrics(
_ context: ModelContext, defaults: UserDefaults, network: LifecycleNetwork, environment: String
) throws -> SyncSafetyAdapter {
let persistence = MetricsSyncPersistence(context: context, defaults: defaults)
let journal = persistence.journal
let envelopes = (0..<2).map { index in
let payload = MetricsRecordPayload.selection(
SyncedMetricsSelection(selection: MetricsSelection(id: UUID()), sortIndex: index))
return MetricsEnvelope(key: payload.key, version: version, payload: payload)
}
for envelope in envelopes { try journal.upsert(envelope, needsUpload: true) }
let coordinator = MetricsSyncCoordinator(
persistence: persistence, defaults: defaults, environmentIdentifier: environment,
makeTransport: { _, _ in network.transport })
return SyncSafetyAdapter(
lifecycle: coordinator.lifecycle, recordNames: envelopes.map(\.key.recordName),
receive: { try coordinator.receiveRemoteRecords($0, deletions: $1) },
conflict: coordinator.handleFailedSave,
batch: { coordinator.makeChangeBatch($0, engine: network.nextEngine) },
record: { coordinator.recordToSave($0, engine: network.nextEngine) },
pendingNames: { Set(try journal.pendingEnvelopes().map(\.key.recordName)) },
markTombstone: {
var envelope = envelopes[0]
envelope.isDeleted = true
envelope.payload = nil
envelope.version.counter += 1
try journal.upsert(envelope, needsUpload: true)
try context.save()
},
failingMerge: { safety in
var envelope = envelopes[0]
envelope.version.counter += 1
_ = try persistence.applyRemote(
[envelope],
beforeSave: {
try safety.clearQuarantine(recordName: envelope.key.recordName)
throw CocoaError(.fileWriteUnknown)
})
})
}
func start() async throws {
lifecycle.start()
await lifecycle.operation?.value
#expect(lifecycle.isReadyToSend)
_ = try #require(lifecycle.scope)
}
func stop() {
lifecycle.pause()
defaults.removePersistentDomain(forName: defaultsName)
}
func safety() throws -> CloudSyncSafetyStore {
CloudSyncSafetyStore(context: container.mainContext, scope: try #require(lifecycle.scope))
}
func futureRecord() throws -> CKRecord {
let record = try #require(record(recordIDs[0]))
let data = try #require(record["payload"] as? Data)
var json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
json["formatVersion"] = 99
let payload = try JSONSerialization.data(withJSONObject: json, options: .sortedKeys)
Self.setPayload(payload, in: record)
record["formatVersion"] = NSNumber(value: 99)
return record
}
static func setPayload(_ data: Data, in record: CKRecord) {
record["payload"] = data as NSData
record["payloadHash"] = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() as NSString
}
}
// Seed exactly the mutations under test without bootstrapping unrelated application defaults.
extension VaultSyncJournal {
fileprivate func upsert(_ envelope: VaultEnvelope, needsUpload: Bool) throws {
try acceptRemote([envelope])
if needsUpload { try markNeedsUpload([envelope.key]) }
}
}
extension SettingsSyncJournal {
fileprivate func upsert(_ envelope: SettingsEnvelope, needsUpload: Bool) throws {
try acceptRemote([envelope])
if needsUpload { try markNeedsUpload([envelope.key]) }
}
}
extension MetricsSyncJournal {
fileprivate func upsert(_ envelope: MetricsEnvelope, needsUpload: Bool) throws {
try acceptRemote([envelope])
if needsUpload { try markNeedsUpload([envelope.key]) }
}
}