Files
Termind/TFMTests/SettingsSyncTests.swift
T

686 lines
35 KiB
Swift

import AgentKit
import CloudKit
import CoreData
import CryptoKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
@testable import Termind
@Suite
@MainActor
struct SettingsSyncTests {
private func fixture() throws -> (ModelContainer, SettingsSyncPersistence, UserDefaults) {
let container = try ModelContainer(
for: HostPersistence.schema,
configurations: localModelConfiguration(isStoredInMemoryOnly: true))
let defaults = UserDefaults(suiteName: "SettingsSyncTests.\(UUID())")!
let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
try store.prepare()
return (container, store, defaults)
}
private func provider(_ name: String = "Private endpoint") -> ModelProvider {
var model = AIModel(id: "private-chat")
model.contextLength = 123_456
model.isShownInAssistant = false
model.reasoningProfile = ReasoningProfile(levels: [
.high: ReasoningLevelMapping(isSupported: true, wireValue: "deep")
])
return ModelProvider(
name: name, inferenceURL: "https://example.invalid/v1/responses",
credentialRef: "termind.ai.key.\(UUID())", models: [model])
}
private func envelope(_ payload: SettingsRecordPayload, time: TimeInterval = 1) -> SettingsEnvelope {
SettingsEnvelope(
key: payload.key,
version: .next(after: nil, writerID: UUID(), now: Date(timeIntervalSince1970: time)),
payload: payload)
}
private func legacyGuardianData(_ envelope: SettingsEnvelope) throws -> Data {
let data = try SettingsSyncCodec.encode(envelope)
let current = String(decoding: data, as: UTF8.self)
return Data(
current.replacingOccurrences(
of: AIRole.guardian.rawValue,
with: SettingsRecordKey.legacySecurityReviewID
).utf8
)
}
private func payloadHash(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
@Test
func schemaV13RetainsItsInstalledEntityHashes() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("V13Hashes-\(UUID())")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("Store.sqlite")
let schema = Schema(versionedSchema: TermindSchemaV13.self)
let container = try ModelContainer(
for: schema,
configurations: localModelConfiguration("V13", schema: schema, url: url))
defer { withExtendedLifetime(container) {} }
let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(ofType: NSSQLiteStoreType, at: url)
let hashes = try #require(metadata[NSStoreModelVersionHashesKey] as? [String: Data])
let expected = [
"PersistentAIConfigurationSyncEntry": "005de4241162667db1f82f46fabcabc1401dc22a3a4e9586d798787dc5e26281",
"PersistentAIConfigurationSyncState": "72ccff1781b06084d643d3ea43ad62ac85ac9840835db04aaae34b4776d74998",
"PersistentAIConfigurationSyncQuarantine":
"1ad4dabecf147f68f4419de6a0818b554932822dfb8e83a8e02560ad48b2b452",
"PersistentAIWebSearchPreferences": "01922c7bda7dd72129b8e0e8079eb39ac44718aa5421160038defcda4c6ddb37",
"PersistentAISecretLocation": "3a0b34347788b6e9da55f343572be6dc942d0b576c72039f520a21724d26bf87",
]
for (name, expectedHash) in expected {
#expect(hashes[name]?.map { String(format: "%02x", $0) }.joined() == expectedHash)
}
}
@Test
func schemaV12MigratesProvidersSkillsAndPreferencesWithoutMaterializingSyncSelection() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("AISyncMigration-\(UUID())")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("Store.sqlite")
let oldSchema = Schema(versionedSchema: TermindSchemaV12.self)
let provider = provider()
let endpoint = ProviderEndpoint(apiType: .responses, url: "https://example.invalid/v1")
let skill = AgentSkill(name: "custom", title: "Custom", summary: "Mine", body: "Do this")
var legacy: ModelContainer? = try ModelContainer(
for: oldSchema,
configurations: localModelConfiguration("Migration", schema: oldSchema, url: url))
legacy!.mainContext.insert(PersistentModelProvider(provider, endpoint: endpoint, sortIndex: 0))
for (index, model) in provider.models.enumerated() {
legacy!.mainContext.insert(PersistentAIModel(model, providerID: provider.id, sortIndex: index))
}
legacy!.mainContext.insert(PersistentAgentSkill(skill, sortIndex: 0))
try legacy!.mainContext.save()
legacy = nil
let migrated = try ModelContainer(
for: HostPersistence.schema, migrationPlan: TermindMigrationPlan.self,
configurations: localModelConfiguration("Migration", schema: HostPersistence.schema, url: url))
let defaults = UserDefaults(suiteName: "AISyncMigration.\(UUID())")!
defaults.set(false, forKey: SettingsKey.webSearchEnabled)
defaults.set(17.0, forKey: SettingsKey.terminalFontSize)
defaults.set("font-family = Menlo", forKey: SettingsKey.terminalCustomConfigurationDark)
let store = SettingsSyncPersistence(context: migrated.mainContext, defaults: defaults)
try store.prepare()
let snapshot = try store.load()
#expect(snapshot.configuration.providers.first?.id == provider.id)
#expect(snapshot.configuration.endpoints[provider.id] == endpoint)
#expect(snapshot.configuration.providers.first?.models == provider.models)
#expect(snapshot.skills == [skill])
#expect(!snapshot.webSearch.isEnabled)
#expect(defaults.double(forKey: SettingsKey.terminalFontSize) == 17)
#expect(snapshot.terminal.darkCustomConfiguration == "font-family = Menlo")
#expect(defaults.object(forKey: SettingsKey.settingsSyncEnabled) == nil)
}
@Test
func completedV13BootstrapAddsTerminalWithoutRewritingExistingRecords() throws {
let (container, store, defaults) = try fixture()
defer { withExtendedLifetime(container) {} }
let previous = try store.journal.pendingEnvelopes().filter { $0.key.kind != .terminal }
try container.mainContext.delete(
model: PersistentAIConfigurationSyncEntry.self,
where: #Predicate { $0.kind == "terminal" })
try container.mainContext.delete(model: PersistentTerminalPreferences.self)
try container.mainContext.save()
defaults.set(17.0, forKey: SettingsKey.terminalFontSize)
try store.prepare()
let terminal = try #require(try store.journal.pendingEnvelopes().first { $0.key.kind == .terminal })
#expect(terminal.version.modifiedAt == VaultVersion.baselineDate)
#expect(defaults.double(forKey: SettingsKey.terminalFontSize) == 17)
for old in previous { #expect(try store.journal.envelope(for: old.key) == old) }
}
@Test
func terminalPreferencesSyncWithCustomTextAndMirrorExistingRuntimeKeys() throws {
let (sourceContainer, source, sourceDefaults) = try fixture()
let (destinationContainer, destination, defaults) = try fixture()
defer { withExtendedLifetime((sourceContainer, destinationContainer)) {} }
var preferences = TerminalPreferences()
sourceDefaults.set(18.0, forKey: SettingsKey.terminalFontSize)
sourceDefaults.set(false, forKey: SettingsKey.terminalFontFollowTheme)
preferences.darkTheme = "Catppuccin Mocha"
preferences.lightTheme = "GitHub Light"
preferences.darkCustomEnabled = true
preferences.lightCustomEnabled = true
preferences.darkCustomConfiguration = "background = #112233\nfont-family = Menlo"
preferences.lightCustomConfiguration = "background = #ffffff"
preferences.shellIntegration = false
preferences.remoteWindowTitleInMenuBar = false
preferences.copyOnSelect = true
preferences.pasteOnRightClick = true
try source.saveTerminal(preferences)
#expect(sourceDefaults.double(forKey: SettingsKey.terminalFontSize) == 18)
let records = try source.journal.pendingEnvelopes().filter { $0.key.kind == .terminal }
let result = try destination.applyRemote(records)
#expect(result.snapshot.terminal == preferences)
#expect(
defaults.string(forKey: SettingsKey.terminalCustomConfigurationDark) == preferences.darkCustomConfiguration)
#expect(!defaults.bool(forKey: SettingsKey.shellIntegration))
#expect(defaults.bool(forKey: SettingsKey.terminalCopyOnSelect))
#expect(defaults.bool(forKey: SettingsKey.terminalPasteOnRightClick))
#expect(try !destination.journal.pendingEnvelopes().contains { $0.key.kind == .terminal })
let model = TerminalSettingsModel(persistence: destination)
#expect(model.set(true, for: \.shellIntegration))
#expect(model.preferences.shellIntegration)
#expect(try destination.loadTerminal().shellIntegration)
model.applyRemoteSnapshot(preferences)
#expect(!model.preferences.shellIntegration)
}
@Test
func everyRecordRoundTripsWithoutSecretsOrLocalExecutionSettings() throws {
let (container, store, _) = try fixture()
defer { withExtendedLifetime(container) {} }
let provider = provider()
var snapshot = try store.load()
snapshot.configuration.providers = [provider]
snapshot.configuration.endpoints[provider.id] = ProviderEndpoint(
apiType: .responses, url: "https://example.invalid/v1")
let ref = AIModelRef(modelProviderID: provider.id, modelID: provider.models[0].id)
snapshot.configuration.roles[.chat] = ref
snapshot.configuration.lastSelectedModel = ref
snapshot.configuration.mcpServers = [
MCPServer(
name: "Remote", credentialRef: "termind.ai.mcp.ref",
headers: [MCPHeader(name: "X-Tenant", value: "tenant")], tools: [MCPTool(id: "read")])
]
for payload in snapshot.recordPayloads.values {
let value = envelope(payload)
let data = try SettingsSyncCodec.encode(value)
#expect(try SettingsSyncCodec.decode(data) == value)
#expect(SettingsRecordKey(recordName: value.key.recordName) == value.key)
let json = String(decoding: data, as: UTF8.self)
#expect(!json.contains("permissionMode"))
#expect(!json.contains("lastConnectedAt"))
#expect(!json.contains(MCPServerTokenStore.reference))
let record = try SettingsSyncCoordinator.makeRecord(
SettingsOutgoingEntry(envelope: value),
recordID: CKRecord.ID(
recordName: value.key.recordName,
zoneID: CKRecordZone.ID(zoneName: SettingsSyncCoordinator.zoneName)))
#expect(try SettingsSyncCoordinator.envelope(from: record) == value)
}
let wire = SyncedModelProvider(provider, endpoint: snapshot.configuration.endpoints[provider.id]!, sortIndex: 0)
#expect(wire.value.models == provider.models)
}
@Test
func chatAndDefaultRoleOptionsSyncAsLiveValues() throws {
let (sourceContainer, source, _) = try fixture()
let (destinationContainer, destination, _) = try fixture()
defer { withExtendedLifetime((sourceContainer, destinationContainer)) {} }
var provider = provider()
provider.models.append(AIModel(id: "second-chat"))
let first = AIModelRef(modelProviderID: provider.id, modelID: provider.models[0].id)
let second = AIModelRef(modelProviderID: provider.id, modelID: provider.models[1].id)
var configuration = try source.load().configuration
configuration.providers = [provider]
configuration.roles = [.chat: first, .commandGenerator: second, .guardian: first]
try source.saveConfiguration(configuration)
_ = try destination.applyRemote(source.journal.pendingEnvelopes())
#expect(try destination.load().configuration.roles == configuration.roles)
configuration.roles = [.chat: second]
try source.saveConfiguration(configuration)
let keys = [AIRole.chat, .commandGenerator, .guardian].map {
SettingsRecordKey(kind: .role, id: $0.rawValue)
}
let changes = try keys.compactMap { try source.journal.envelope(for: $0) }
let result = try destination.applyRemote(changes.reversed())
#expect(result.snapshot.configuration.roles == [.chat: second])
for role in [AIRole.commandGenerator, .guardian] {
let key = SettingsRecordKey(kind: .role, id: role.rawValue)
guard case .role(let storedRole, nil) = try source.journal.envelope(for: key)?.payload else {
Issue.record("Missing live default role payload for \(role.rawValue)")
continue
}
#expect(storedRole == role)
}
let resolved = AIConfigurationModel(
modelProviders: result.snapshot.configuration.providers,
roles: result.snapshot.configuration.roles,
persistsSecrets: false
)
#expect(resolved.resolve(.chat)?.model.id == "second-chat")
#expect(resolved.resolve(.commandGenerator)?.model.id == "second-chat")
#expect(resolved.resolve(.guardian)?.model.id == "second-chat")
}
@Test
func legacySecurityReviewRecordDecodesAsCanonicalGuardian() throws {
let canonical = envelope(.role(.guardian, nil))
let data = try legacyGuardianData(canonical)
let recordID = CKRecord.ID(
recordName: "v1/role/\(SettingsRecordKey.legacySecurityReviewID)",
zoneID: CKRecordZone.ID(zoneName: SettingsSyncCoordinator.zoneName)
)
let record = CKRecord(recordType: SettingsSyncCoordinator.recordType, recordID: recordID)
record["payload"] = data as NSData
record["payloadHash"] = payloadHash(data) as NSString
let decoded = try SettingsSyncCoordinator.envelope(from: record)
#expect(decoded.key == SettingsRecordKey(kind: .role, id: AIRole.guardian.rawValue))
#expect(decoded.payload == .role(.guardian, nil))
#expect(decoded.isValid)
}
@Test
func legacyAndCanonicalGuardianRecordsUseLatestVersionRegardlessOfOrder() throws {
let (container, store, _) = try fixture()
defer { withExtendedLifetime(container) {} }
let ref = AIModelRef(modelProviderID: UUID(), modelID: "private-chat")
let legacyCanonical = envelope(.role(.guardian, ref), time: 2)
let legacyData = try legacyGuardianData(legacyCanonical)
let legacyRecord = CKRecord(
recordType: SettingsSyncCoordinator.recordType,
recordID: CKRecord.ID(
recordName: "v1/role/\(SettingsRecordKey.legacySecurityReviewID)",
zoneID: CKRecordZone.ID(zoneName: SettingsSyncCoordinator.zoneName)
)
)
legacyRecord["payload"] = legacyData as NSData
legacyRecord["payloadHash"] = payloadHash(legacyData) as NSString
let legacy = try SettingsSyncCoordinator.envelope(from: legacyRecord)
let automatic = envelope(.role(.guardian, nil), time: 3)
let automaticResult = try store.applyRemote([automatic, legacy])
#expect(automaticResult.snapshot.configuration.roles[.guardian] == nil)
var newerLegacy = legacy
newerLegacy.version = .next(
after: automatic.version,
writerID: UUID(),
now: Date(timeIntervalSince1970: 4)
)
let explicitResult = try store.applyRemote([automatic, newerLegacy])
#expect(explicitResult.snapshot.configuration.roles[.guardian] == ref)
}
@Test
func prepareMigratesLegacySecurityReviewJournalWithoutReusingSystemFields() throws {
let (container, store, _) = try fixture()
defer { withExtendedLifetime(container) {} }
let legacyKey = SettingsRecordKey(kind: .role, id: SettingsRecordKey.legacySecurityReviewID)
let canonicalKey = SettingsRecordKey(kind: .role, id: AIRole.guardian.rawValue)
let ref = AIModelRef(modelProviderID: UUID(), modelID: "private-chat")
let canonical = envelope(.role(.guardian, ref), time: 5)
let legacyData = try legacyGuardianData(canonical)
try container.mainContext.delete(
model: PersistentAIConfigurationSyncEntry.self,
where: #Predicate { $0.recordName == canonicalKey.recordName }
)
let legacy = try PersistentAIConfigurationSyncEntry(envelope: canonical, needsUpload: false)
legacy.recordName = legacyKey.recordName
legacy.kind = legacyKey.kind.rawValue
legacy.entityID = legacyKey.id
legacy.envelopeData = legacyData
legacy.systemFieldsData = Data([1, 2, 3])
container.mainContext.insert(legacy)
container.mainContext.insert(
PersistentAIConfigurationSyncQuarantine(
recordName: legacyKey.recordName,
payloadData: legacyData,
reason: "legacy"
)
)
let state = try store.journal.state()
state.engineStateData = Data([4, 5, 6])
try container.mainContext.save()
try store.prepare()
#expect(try store.journal.envelope(for: legacyKey) == nil)
let migrated = try #require(try store.journal.outgoingEntry(for: canonicalKey))
#expect(migrated.envelope.payload == .role(.guardian, ref))
#expect(migrated.systemFieldsData == nil)
#expect(try store.journal.state().engineStateData == nil)
#expect(try store.journal.firstQuarantineReason() == nil)
}
@Test
func localEditsAreJournaledWhileDisabledAndNoOpsDoNotAdvanceVersions() throws {
let (container, store, defaults) = try fixture()
defer { withExtendedLifetime(container) {} }
defaults.set(false, forKey: SettingsKey.settingsSyncEnabled)
#expect(!defaults.bool(forKey: SettingsKey.settingsSyncEnabled))
var snapshot = try store.load().configuration
let provider = provider()
snapshot.providers.append(provider)
try store.saveConfiguration(snapshot)
let key = SettingsRecordKey(kind: .provider, id: provider.id.uuidString.lowercased())
let original = try #require(try store.journal.envelope(for: key))
try store.saveConfiguration(snapshot)
#expect(try store.journal.envelope(for: key) == original)
snapshot.providers.removeAll()
try store.saveConfiguration(snapshot)
#expect(try store.journal.envelope(for: key)?.isDeleted == true)
let merged = try store.applyRemote([original])
#expect(merged.snapshot.configuration.providers.isEmpty)
#expect(merged.needsUpload.contains(key))
}
@Test
func newDeviceDefaultsDoNotReplaceCloudPreferencesAndBuiltInDeletion() throws {
let (sourceContainer, source, _) = try fixture()
defer { withExtendedLifetime(sourceContainer) {} }
let (destinationContainer, destination, _) = try fixture()
defer { withExtendedLifetime(destinationContainer) {} }
try source.saveWebSearch(AIWebSearchPreferences(isEnabled: false, prefersBuiltIn: false))
try source.saveSkills([])
let result = try destination.applyRemote(source.journal.pendingEnvelopes())
#expect(result.snapshot.skills.isEmpty)
#expect(!result.snapshot.webSearch.isEnabled)
try destination.prepare()
#expect(try destination.load().skills.isEmpty)
}
@Test
func roleArrivingBeforeProviderSurvivesRelaunchAndUnrelatedEdit() throws {
let (container, store, defaults) = try fixture()
let provider = provider()
let ref = AIModelRef(modelProviderID: provider.id, modelID: provider.models[0].id)
let role = envelope(.role(.chat, ref))
_ = try store.applyRemote([role])
let reopened = SettingsSyncPersistence(context: ModelContext(container), defaults: defaults)
var config = try reopened.load().configuration
#expect(config.roles[.chat] == ref)
config.mcpServers = [MCPServer(name: "Unrelated")]
try reopened.saveConfiguration(config)
let value = SyncedModelProvider(provider, endpoint: ProviderEndpoint(url: provider.inferenceURL), sortIndex: 0)
let result = try reopened.applyRemote([envelope(.provider(value), time: 2)])
#expect(result.snapshot.configuration.roles[.chat] == ref)
let deletion = SettingsEnvelope(
key: valueKey(provider),
version: .next(after: nil, writerID: UUID(), now: Date(timeIntervalSince1970: 3)), isDeleted: true)
let deleted = try reopened.applyRemote([deletion])
#expect(deleted.snapshot.configuration.roles[.chat] == nil)
}
private func valueKey(_ provider: ModelProvider) -> SettingsRecordKey {
SettingsRecordKey(kind: .provider, id: provider.id.uuidString.lowercased())
}
@Test
func differentProvidersMergeAndLatestVersionWinsRegardlessOfBatchOrder() throws {
let (container, store, _) = try fixture()
defer { withExtendedLifetime(container) {} }
let first = provider("First")
let second = provider("Second")
let endpoint = ProviderEndpoint(url: first.inferenceURL)
let old = envelope(.provider(SyncedModelProvider(first, endpoint: endpoint, sortIndex: 0)), time: 1)
var changed = first
changed.name = "Updated"
let newer = envelope(.provider(SyncedModelProvider(changed, endpoint: endpoint, sortIndex: 0)), time: 5)
let other = envelope(.provider(SyncedModelProvider(second, endpoint: endpoint, sortIndex: 1)), time: 2)
let result = try store.applyRemote([newer, other, old])
#expect(result.snapshot.configuration.providers.count == 2)
#expect(result.snapshot.configuration.providers.first?.name == "Updated")
}
@Test
func invalidBatchRollsBackConfigurationAndJournal() throws {
let (container, store, _) = try fixture()
defer { withExtendedLifetime(container) {} }
let before = try store.load().webSearch
let valid = envelope(.webSearch(.init(isEnabled: false, prefersBuiltIn: false)))
var invalid = envelope(.terminal(TerminalPreferences()))
invalid.formatVersion = 99
#expect(throws: (any Error).self) { try store.applyRemote([valid, invalid]) }
#expect(try store.load().webSearch == before)
#expect(try store.journal.envelope(for: valid.key)?.version.modifiedAt == VaultVersion.baselineDate)
}
@Test
func environmentResetRetainsDeletesButAccountMergeDoesNotUploadOldAccountDeletes() throws {
let (container, store, _) = try fixture()
defer { withExtendedLifetime(container) {} }
try store.saveSkills([AgentSkill(name: "deleted", summary: "s", body: "b")])
try store.saveSkills([])
try store.journal.markAllEntriesNeedsUploadAfterEnvironmentChange()
#expect(try store.journal.pendingEnvelopes().contains(where: \.isDeleted))
try store.journal.markAllLiveRecordsNeedsUploadForNewAccount()
#expect(try !store.journal.pendingEnvelopes().contains(where: \.isDeleted))
}
@Test func existingSkillsRemainUserOwnedAcrossPreparationAndSync() throws {
let (container, store, _) = try fixture()
defer { withExtendedLifetime(container) {} }
let existing = AgentSkill(
name: "existing-procedure", title: "My Procedure", summary: "Keep the installed procedure.",
body: "Read the existing instructions.", isEnabled: false,
package: .init(files: ["notes.md": Data("My notes".utf8)]))
try store.saveSkills([existing])
let key = SettingsRecordKey(kind: .skill, id: existing.id.uuidString.lowercased())
let original = try #require(try store.journal.envelope(for: key))
try store.prepare()
try store.prepare()
#expect(try store.load().skills == [existing])
#expect(try store.journal.envelope(for: key) == original)
var updated = existing
updated.body = "Instructions edited on another device."
let incoming = SettingsEnvelope(
key: key, version: .next(after: original.version, writerID: UUID(), now: .now),
payload: .skill(updated, sortIndex: 0))
let result = try store.applyRemote([incoming])
#expect(result.snapshot.skills == [updated])
#expect(try store.journal.envelope(for: key)?.isDeleted == false)
let library = SkillLibraryModel(persistence: SyncedSkillLibraryPersistence(store))
#expect(library.skills == [updated])
library.delete(id: updated.id)
try store.prepare()
#expect(try store.load().skills.isEmpty)
}
@Test
func collisionsAreDeterministicallyExcludedWithoutRewritingUserRecords() {
let low = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
let high = UUID(uuidString: "00000000-0000-0000-0000-000000000002")!
let first = MCPServer(id: low, name: "Shared")
let second = MCPServer(id: high, name: "Shared")
let ai = AIConfigurationModel(mcpServers: [second, first], persistsSecrets: false)
#expect(ai.usableMCPServers.map(\.id) == [low])
#expect(ai.mcpServers.allSatisfy { $0.isEnabled })
let library = SkillLibraryModel(skills: [
AgentSkill(id: high, name: "shared", title: "Second", summary: "Procedure", body: "Second"),
AgentSkill(id: low, name: "shared", title: "First", summary: "Procedure", body: "First"),
])
#expect(library.enabledSkills.map(\.id) == AgentSkillLibrary.builtIns.map(\.id) + [low])
}
@Test
func mainSwitchPausesBothCategoriesWithoutClearingSelections() throws {
let (container, store, defaults) = try fixture()
SettingsKey.migrateSyncPreferences(in: defaults)
#expect(defaults.bool(forKey: SettingsKey.iCloudSyncEnabled))
#expect(defaults.object(forKey: SettingsKey.vaultSyncEnabled) as? Bool ?? SettingsKey.defaultVaultSyncEnabled)
#expect(defaults.object(forKey: SettingsKey.settingsSyncEnabled) == nil)
let vault = VaultSyncCoordinator(context: container.mainContext, defaults: defaults)
let ai = SettingsSyncCoordinator(
persistence: store,
secrets: AISecretStore(
context: container.mainContext, keychain: SyncTestSecrets(), defaults: defaults), defaults: defaults)
#expect(vault.isEnabled && ai.isEnabled)
defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled)
#expect(!vault.isEnabled && !ai.isEnabled)
#expect(defaults.object(forKey: SettingsKey.settingsSyncEnabled) == nil)
defaults.set(true, forKey: SettingsKey.iCloudSyncEnabled)
#expect(vault.isEnabled && ai.isEnabled)
defaults.set(false, forKey: SettingsKey.vaultSyncEnabled)
#expect(!vault.isEnabled && ai.isEnabled)
}
@Test
func newSettingsDefaultKeepsMainSyncOnWhenVaultWasDisabled() {
let defaults = UserDefaults(suiteName: "AISyncPreferenceMigration.\(UUID())")!
defaults.set(false, forKey: SettingsKey.vaultSyncEnabled)
SettingsKey.migrateSyncPreferences(in: defaults)
#expect(defaults.bool(forKey: SettingsKey.iCloudSyncEnabled))
#expect(!defaults.bool(forKey: SettingsKey.vaultSyncEnabled))
#expect(defaults.object(forKey: SettingsKey.settingsSyncEnabled) == nil)
#expect(
defaults.object(forKey: SettingsKey.settingsSyncEnabled) as? Bool
?? SettingsKey.defaultSettingsSyncEnabled)
}
@Test
func explicitSettingsOptOutRemainsDisabledDuringPreferenceMigration() {
let defaults = UserDefaults(suiteName: "AISyncPreferenceOptOutMigration.\(UUID())")!
defaults.set(false, forKey: SettingsKey.vaultSyncEnabled)
defaults.set(false, forKey: SettingsKey.settingsSyncEnabled)
SettingsKey.migrateSyncPreferences(in: defaults)
#expect(!defaults.bool(forKey: SettingsKey.iCloudSyncEnabled))
#expect(defaults.bool(forKey: SettingsKey.vaultSyncEnabled))
#expect(!defaults.bool(forKey: SettingsKey.settingsSyncEnabled))
}
@Test
func independentSwitchesUseDefaultsAndHonorExplicitOptOut() throws {
let (container, store, defaults) = try fixture()
defaults.set(true, forKey: SettingsKey.vaultSyncEnabled)
let vault = VaultSyncCoordinator(context: container.mainContext, defaults: defaults)
let ai = SettingsSyncCoordinator(
persistence: store,
secrets: AISecretStore(
context: container.mainContext, keychain: SyncTestSecrets(), defaults: defaults), defaults: defaults)
#expect(vault.isEnabled && ai.isEnabled)
vault.setEnabled(false)
#expect(ai.isEnabled)
defaults.set(false, forKey: SettingsKey.settingsSyncEnabled)
#expect(!ai.isEnabled)
#expect(!vault.isEnabled)
ai.pause()
}
@Test
func keysMigrateRetryAndRemainSyncedWhenConfigurationPauses() throws {
let (container, _, defaults) = try fixture()
let keychain = SyncTestSecrets()
let secrets = AISecretStore(context: container.mainContext, keychain: keychain, defaults: defaults)
let ref = "termind.ai.key.test"
try secrets.setString("original", for: ref)
#expect(try keychain.string(for: ref, storage: .iCloud) == nil)
secrets.uploadsAuthorized = true
keychain.failCloudWrites = true
#expect(throws: (any Error).self) { try secrets.migrate([ref]) }
#expect(try keychain.string(for: ref, storage: .thisDevice) == "original")
keychain.failCloudWrites = false
try secrets.migrate([ref])
#expect(try keychain.string(for: ref, storage: .thisDevice) == nil)
secrets.uploadsAuthorized = false
try secrets.setString("new", for: ref)
#expect(try keychain.string(for: ref, storage: .iCloud) == "new")
try keychain.setString("other device", for: ref, storage: .iCloud)
#expect(try secrets.string(for: ref) == "other device")
try secrets.setString(nil, for: ref)
try keychain.setString("delayed stale item", for: ref, storage: .iCloud)
#expect(try secrets.string(for: ref) == nil)
}
@Test
func newAISecretsFollowBothSwitchesAndMigrateOnEveryEnable() throws {
let (container, _, defaults) = try fixture()
let keychain = SyncTestSecrets()
let secrets = AISecretStore(context: container.mainContext, keychain: keychain, defaults: defaults)
let first = "termind.ai.key.first-enable"
let second = "termind.ai.key.second-enable"
secrets.uploadsAuthorized = true
defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled)
defaults.set(true, forKey: SettingsKey.settingsSyncEnabled)
try secrets.setString("first", for: first)
try secrets.migrate([first])
#expect(try keychain.string(for: first, storage: .thisDevice) == "first")
#expect(try keychain.string(for: first, storage: .iCloud) == nil)
defaults.set(true, forKey: SettingsKey.iCloudSyncEnabled)
try secrets.migrate([first])
#expect(try keychain.string(for: first, storage: .thisDevice) == nil)
#expect(try keychain.string(for: first, storage: .iCloud) == "first")
defaults.set(false, forKey: SettingsKey.settingsSyncEnabled)
try secrets.setString("second", for: second)
try secrets.migrate([second])
try secrets.setString("first-updated", for: first)
#expect(try keychain.string(for: second, storage: .thisDevice) == "second")
#expect(try keychain.string(for: second, storage: .iCloud) == nil)
#expect(try keychain.string(for: first, storage: .thisDevice) == nil)
#expect(try keychain.string(for: first, storage: .iCloud) == "first-updated")
defaults.set(true, forKey: SettingsKey.settingsSyncEnabled)
try secrets.migrate([first, second])
#expect(try keychain.string(for: second, storage: .thisDevice) == nil)
#expect(try keychain.string(for: second, storage: .iCloud) == "second")
}
@Test
func remoteMCPTokenMigratesWithoutSelectingTheBuiltInServerToken() throws {
let (container, _, defaults) = try fixture()
let keychain = SyncTestSecrets()
let secrets = AISecretStore(context: container.mainContext, keychain: keychain, defaults: defaults)
let remoteReference = "termind.ai.mcp.remote"
try secrets.setString("remote", for: remoteReference)
try keychain.setString(
"listener", for: MCPServerTokenStore.reference, storage: .thisDevice)
secrets.uploadsAuthorized = true
try secrets.migrate([remoteReference])
#expect(try keychain.string(for: remoteReference, storage: .iCloud) == "remote")
#expect(try keychain.string(for: MCPServerTokenStore.reference, storage: .thisDevice) == "listener")
#expect(try keychain.string(for: MCPServerTokenStore.reference, storage: .iCloud) == nil)
}
@Test
func remoteKeyCanArriveAfterConfigurationWithoutUsingStaleLocalCopy() throws {
let (container, _, defaults) = try fixture()
let keychain = SyncTestSecrets()
let secrets = AISecretStore(context: container.mainContext, keychain: keychain, defaults: defaults)
let ref = "termind.ai.mcp.delayed"
try keychain.setString("stale", for: ref, storage: .thisDevice)
try secrets.acceptRemoteReferences([ref])
#expect(try secrets.string(for: ref) == nil)
try keychain.setString("arrived", for: ref, storage: .iCloud)
#expect(try secrets.string(for: ref) == "arrived")
}
}
private nonisolated final class SyncTestSecrets: SecretStoring, @unchecked Sendable {
private let lock = NSLock()
private var values: [String: String] = [:]
var failCloudWrites = false
func string(for account: String, storage: PasswordStorage) throws -> String? {
lock.withLock { values["\(storage):\(account)"] }
}
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
try lock.withLock {
if storage == .iCloud, failCloudWrites { throw KeychainSecretError.unexpectedValue }
values["\(storage):\(account)"] = value
}
}
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? { nil }
func setKeyCredential(_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage) throws {}
func removePassword(_ account: String, storage: PasswordStorage) throws {
try setString(nil, for: account, storage: storage)
}
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {}
}