diff --git a/FrameworkTests/IdentityCredentialUsageTests.swift b/FrameworkTests/IdentityCredentialUsageTests.swift index 19e38eb8..55f203f5 100644 --- a/FrameworkTests/IdentityCredentialUsageTests.swift +++ b/FrameworkTests/IdentityCredentialUsageTests.swift @@ -130,11 +130,12 @@ struct IdentityCredentialUsageTests { let dates = snapshot.identities.compactMap { store.identity($0.id)?.lastUsedAt } #expect(dates.count == 3) #expect(Set(dates).count == 1) - #expect(persistence.saveCount == 1) + #expect(persistence.identityUsageSaveCount == 1) + #expect(persistence.saveCount == 0) } @Test - func usageDatesAreMonotonicAndFailedPersistenceRollsBack() { + func usageDatesAreMonotonicAndRepeatedReadsPersistOncePerProcess() { let identity = Identity(name: "Deploy", username: "deploy") let persistence = CredentialAccessPersistence( snapshot: VaultSnapshot(identities: [identity]) @@ -148,14 +149,59 @@ struct IdentityCredentialUsageTests { ) #expect(store.identity(identity.id)?.lastUsedAt == newer) - #expect(persistence.saveCount == 1) + #expect(persistence.identityUsageSaveCount == 1) - persistence.failsSave = true store.recordIdentityCredentialAccess( [identity.id], at: Date(timeIntervalSince1970: 300) ) #expect(store.identity(identity.id)?.lastUsedAt == newer) + #expect(persistence.identityUsageSaveCount == 1) + } + + @Test + func failedUsagePersistenceRollsBackAndCanRetry() { + let identity = Identity(name: "Deploy", username: "deploy") + let persistence = CredentialAccessPersistence( + snapshot: VaultSnapshot(identities: [identity]) + ) + let store = HostLibraryModel(persistence: persistence) + let usedAt = Date(timeIntervalSince1970: 300) + + persistence.failsIdentityUsageSave = true + store.recordIdentityCredentialAccess([identity.id], at: usedAt) + #expect(store.identity(identity.id)?.lastUsedAt == nil) #expect(store.storageError != nil) + + persistence.failsIdentityUsageSave = false + store.recordIdentityCredentialAccess([identity.id], at: usedAt) + #expect(store.identity(identity.id)?.lastUsedAt == usedAt) + #expect(persistence.identityUsageSaveCount == 2) + #expect(store.storageError == nil) + } + + @Test + func staleIdentitySavePreservesUsageAndCredentialChangesAllowAnotherRecord() throws { + let usedAt = Date(timeIntervalSince1970: 200) + let identity = Identity(name: "Deploy", username: "deploy") + let persistence = CredentialAccessPersistence( + snapshot: VaultSnapshot(identities: [identity]) + ) + let store = HostLibraryModel(persistence: persistence) + store.recordIdentityCredentialAccess([identity.id], at: usedAt) + + var edited = identity + edited.name = "Renamed" + edited.lastUsedAt = Date(timeIntervalSince1970: 100) + #expect(store.upsert(edited)) + #expect(store.identity(identity.id)?.lastUsedAt == usedAt) + + edited = try #require(store.identity(identity.id)) + edited.username = "root" + #expect(store.upsert(edited)) + let later = Date(timeIntervalSince1970: 300) + store.recordIdentityCredentialAccess([identity.id], at: later) + #expect(store.identity(identity.id)?.lastUsedAt == later) + #expect(persistence.identityUsageSaveCount == 2) } @Test @@ -187,9 +233,11 @@ struct IdentityCredentialUsageTests { @MainActor private final class CredentialAccessPersistence: VaultPersisting { - let snapshot: VaultSnapshot + var snapshot: VaultSnapshot var saveCount = 0 var failsSave = false + var identityUsageSaveCount = 0 + var failsIdentityUsageSave = false init(snapshot: VaultSnapshot) { self.snapshot = snapshot @@ -200,9 +248,21 @@ private final class CredentialAccessPersistence: VaultPersisting { func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet { saveCount += 1 if failsSave { throw CocoaError(.fileWriteUnknown) } + self.snapshot = snapshot return .empty } + func saveIdentityLastUsedAt(_ updates: [Identity.ID: Date]) throws { + identityUsageSaveCount += 1 + if failsIdentityUsageSave { throw CocoaError(.fileWriteUnknown) } + for index in snapshot.identities.indices { + guard let date = updates[snapshot.identities[index].id], + snapshot.identities[index].lastUsedAt.map({ $0 >= date }) != true + else { continue } + snapshot.identities[index].lastUsedAt = date + } + } + func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult { VaultRemoteApplyResult(snapshot: snapshot, acceptedKeys: [], needsUpload: []) } diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 9186ad2a..c865b3c6 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -24,6 +24,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { public private(set) var storageError: String? private let vault: VaultStore private let keychain: any SecretStoring + @ObservationIgnored private var recordedIdentityCredentialAccesses: Set = [] /// Runtime resources live in AppModel, so deletion announces before model /// references are removed and the controller can stop listeners cleanly. public var willDeleteHost: ((Host.ID, [PortForwardingRule.ID]) -> Void)? @@ -658,16 +659,46 @@ public final class HostLibraryModel: RemoteSessionHostStore { public func recordIdentityCredentialAccess( _ identityIDs: Set, at date: Date = Date() ) { - guard !identityIDs.isEmpty else { return } + let pending = identityIDs.subtracting(recordedIdentityCredentialAccesses) + guard !pending.isEmpty else { return } let previous = identities - var changed = false - for index in identities.indices where identityIDs.contains(identities[index].id) { - if identities[index].lastUsedAt.map({ $0 >= date }) == true { continue } - identities[index].lastUsedAt = date - changed = true + var updates: [Identity.ID: Date] = [:] + var found: Set = [] + for index in identities.indices where pending.contains(identities[index].id) { + let id = identities[index].id + found.insert(id) + if identities[index].lastUsedAt.map({ $0 >= date }) != true { + identities[index].lastUsedAt = date + updates[id] = date + } } - guard changed else { return } - if !persistCore() { identities = previous } + guard !found.isEmpty else { return } + do { + try vault.saveIdentityLastUsedAt(updates) + recordedIdentityCredentialAccesses.formUnion(found) + storageError = nil + } catch { + identities = previous + storageError = error.localizedDescription + } + } + + private static func identityConnectionFieldsChanged(_ old: Identity, _ new: Identity) -> Bool { + old.username != new.username + || old.credentialRef != new.credentialRef + || old.passwordStorage != new.passwordStorage + || old.keyID != new.keyID + } + + private static func keyConnectionFieldsChanged(_ old: SSHKeyRef, _ new: SSHKeyRef) -> Bool { + old.algorithm != new.algorithm + || old.publicKey != new.publicKey + || old.certificate != new.certificate + || old.fingerprint != new.fingerprint + || old.credentialRef != new.credentialRef + || old.storage != new.storage + || old.hasPassphrase != new.hasPassphrase + || old.requiresUserPresence != new.requiresUserPresence } /// Every host tag, deduplicated case-insensitively and sorted. @@ -957,9 +988,17 @@ public final class HostLibraryModel: RemoteSessionHostStore { /// is sorted by name wherever it is shown, so there is no order to keep. @discardableResult public func upsert(_ identity: Identity) -> Bool { + var resetsCredentialAccess = false if let index = identities.firstIndex(where: { $0.id == identity.id }) { + let previous = identities[index] var updated = identity - updated.sortIndex = identities[index].sortIndex + updated.sortIndex = previous.sortIndex + if let lastUsedAt = previous.lastUsedAt, + updated.lastUsedAt.map({ $0 < lastUsedAt }) != false + { + updated.lastUsedAt = lastUsedAt + } + resetsCredentialAccess = Self.identityConnectionFieldsChanged(previous, updated) identities[index] = updated } else { var new = identity @@ -972,7 +1011,9 @@ public final class HostLibraryModel: RemoteSessionHostStore { hosts[index].proxy?.username = "" } } - return persistCore() + let saved = persistCore() + if saved, resetsCredentialAccess { recordedIdentityCredentialAccesses.remove(identity.id) } + return saved } /// Updates an Identity and its Keychain password as one operation. @@ -1058,6 +1099,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { storageError ?? String(localized: "The identity could not be saved.") ) } + if case .replace = passwordUpdate { recordedIdentityCredentialAccesses.remove(updated.id) } let removesPrevious = !previousReference.isEmpty @@ -1146,11 +1188,14 @@ public final class HostLibraryModel: RemoteSessionHostStore { } return false } + recordedIdentityCredentialAccesses.remove(identityID) return true } public func upsert(_ key: SSHKeyRef, credential: StoredSSHKeyCredential? = nil) throws { let previousKey = self.key(key.id) + let resetsCredentialAccess = + credential != nil || previousKey.map { Self.keyConnectionFieldsChanged($0, key) } == true if key.storage == .secureEnclave, !key.certificate.isEmpty { throw TermindSSHError.library( String(localized: "Secure Enclave keys cannot store an SSH certificate.") @@ -1214,6 +1259,9 @@ public final class HostLibraryModel: RemoteSessionHostStore { storageError ?? String(localized: "The key could not be saved.") ) } + if resetsCredentialAccess { + recordedIdentityCredentialAccesses.subtract(identities(using: key.id).map(\.id)) + } if changesLocation, let previousKey { do { @@ -1266,6 +1314,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { public func delete(keyID: SSHKeyRef.ID) throws { guard let key = key(keyID) else { return } + let affectedIdentityIDs = Set(identities(using: keyID).map(\.id)) let credential = try keyCredential(for: key) try keychain.removeKeyCredential(key.credentialRef, storage: key.storage) let keySnapshot = keys @@ -1286,6 +1335,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { storageError ?? String(localized: "The key could not be deleted.") ) } + recordedIdentityCredentialAccesses.subtract(affectedIdentityIDs) } /// Adds or updates a group. A new group is placed after its last sibling @@ -1751,6 +1801,8 @@ public final class HostLibraryModel: RemoteSessionHostStore { /// Applies a CloudKit-imported projection without writing it back as a new /// local edit. Runtime connection and transfer state intentionally remain. public func replacePersistedSnapshot(_ snapshot: VaultSnapshot) { + let previousIdentities = Dictionary(uniqueKeysWithValues: identities.map { ($0.id, $0) }) + let previousKeys = Dictionary(uniqueKeysWithValues: keys.map { ($0.id, $0) }) hosts = snapshot.hosts groups = snapshot.groups identities = normalizedManualOrder( @@ -1759,6 +1811,23 @@ public final class HostLibraryModel: RemoteSessionHostStore { keys = normalizedManualOrder( snapshot.keys, sortIndex: \SSHKeyRef.sortIndex, name: \SSHKeyRef.label ) + let currentIdentityIDs = Set(identities.map(\.id)) + recordedIdentityCredentialAccesses.formIntersection(currentIdentityIDs) + for identity in identities { + if let previous = previousIdentities[identity.id], + Self.identityConnectionFieldsChanged(previous, identity) + { + recordedIdentityCredentialAccesses.remove(identity.id) + } + } + let currentKeys = Dictionary(uniqueKeysWithValues: keys.map { ($0.id, $0) }) + let changedKeyIDs = Set(previousKeys.keys).union(currentKeys.keys).filter { id in + guard let previous = previousKeys[id], let current = currentKeys[id] else { return true } + return Self.keyConnectionFieldsChanged(previous, current) + } + recordedIdentityCredentialAccesses.subtract( + identities.filter { $0.keyID.map(changedKeyIDs.contains) == true }.map(\.id) + ) knownHosts = snapshot.knownHosts snippets = normalizedSnippetOrder(snapshot.snippets) snippetPackages = snapshot.snippetPackages diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift index 5f952f0f..c8f126e0 100644 --- a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -378,7 +378,11 @@ public final class ServerMetricsModel: MetricsSnapshotReading { encoder.outputFormatting = .sortedKeys var result = (try? encoder.encode(route)) ?? Data() for item in route { - if let identity = hosts.identity(for: item) { + if var identity = hosts.identity(for: item) { + // Credential access updates presentation metadata after a connection + // is prepared. It must not make Metrics mistake that same connection + // for a configuration change and restart its worker on reconciliation. + identity.lastUsedAt = nil result.append((try? encoder.encode(identity)) ?? Data()) if let id = identity.keyID, let key = hosts.key(id) { result.append((try? encoder.encode(key)) ?? Data()) diff --git a/Frameworks/TermindApplication/Repositories/VaultStore.swift b/Frameworks/TermindApplication/Repositories/VaultStore.swift index 2c3d0ee3..34516cec 100644 --- a/Frameworks/TermindApplication/Repositories/VaultStore.swift +++ b/Frameworks/TermindApplication/Repositories/VaultStore.swift @@ -30,6 +30,13 @@ public final class VaultStore { didPersist?() } + /// Device-local usage metadata bypasses the Vault journal and repository + /// refresh callbacks because it does not change connection configuration. + public func saveIdentityLastUsedAt(_ updates: [Identity.ID: Date]) throws { + guard !updates.isEmpty else { return } + try persistence?.saveIdentityLastUsedAt(updates) + } + public func modifiedAt(for key: VaultRecordKey) -> Date? { modificationDates[key] } diff --git a/Frameworks/TermindApplication/VaultPersisting.swift b/Frameworks/TermindApplication/VaultPersisting.swift index c54bfcdd..63525634 100644 --- a/Frameworks/TermindApplication/VaultPersisting.swift +++ b/Frameworks/TermindApplication/VaultPersisting.swift @@ -5,6 +5,7 @@ import TermindDomain public protocol VaultPersisting: AnyObject { func load() throws -> VaultSnapshot func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet + func saveIdentityLastUsedAt(_ updates: [Identity.ID: Date]) throws func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult func modificationDates() throws -> [VaultRecordKey: Date] } diff --git a/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift index 70621446..3a78b5c3 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift @@ -38,7 +38,8 @@ extension VaultSnapshot { for value in groups { result[VaultRecordKey(kind: .hostGroup, id: value.id)] = .hostGroup(value) } - for value in identities { + for var value in identities { + value.lastUsedAt = nil result[VaultRecordKey(kind: .identity, id: value.id)] = .identity(value) } for value in keys { @@ -77,7 +78,9 @@ extension VaultSnapshot { switch payload { case .host(let value): upsert(value, in: &hosts) case .hostGroup(let value): upsert(value, in: &groups) - case .identity(let value): upsert(value, in: &identities) + case .identity(var value): + value.lastUsedAt = identities.first(where: { $0.id == value.id })?.lastUsedAt + upsert(value, in: &identities) case .sshKey(let value): upsert(value, in: &keys) case .knownHost(let value): upsert(value, in: &knownHosts) case .snippet(let value): upsert(value, in: &snippets) @@ -110,6 +113,22 @@ extension VaultSnapshot { } } +extension VaultRecordPayload { + fileprivate var withoutDeviceLocalMetadata: Self { + guard case .identity(var value) = self else { return self } + value.lastUsedAt = nil + return .identity(value) + } +} + +extension VaultEnvelope { + fileprivate var withoutDeviceLocalMetadata: Self { + var copy = self + copy.payload = payload?.withoutDeviceLocalMetadata + return copy + } +} + @MainActor public final class VaultSyncJournal { private let context: ModelContext @@ -183,18 +202,21 @@ public final class VaultSyncJournal { FetchDescriptor( predicate: #Predicate { $0.needsUpload } ) - ).compactMap(\.envelope) + ).compactMap(\.envelope).map(\.withoutDeviceLocalMetadata) } public func outgoingEntry(for key: VaultRecordKey) throws -> VaultOutgoingEntry? { guard let row = try entry(recordName: key.recordName), let envelope = row.envelope else { return nil } - return VaultOutgoingEntry(envelope: envelope, systemFieldsData: row.systemFieldsData) + return VaultOutgoingEntry( + envelope: envelope.withoutDeviceLocalMetadata, + systemFieldsData: row.systemFieldsData + ) } public func envelope(for key: VaultRecordKey) throws -> VaultEnvelope? { - try entry(recordName: key.recordName)?.envelope + try entry(recordName: key.recordName)?.envelope?.withoutDeviceLocalMetadata } public func modificationDates() throws -> [VaultRecordKey: Date] { @@ -209,14 +231,17 @@ public final class VaultSyncJournal { public func mergeDecision(for remote: [VaultEnvelope]) throws -> VaultRemoteMerge { var result = VaultRemoteMerge() let entries = try entriesByName() - for envelope in remote { + for rawEnvelope in remote { + let envelope = rawEnvelope.withoutDeviceLocalMetadata guard envelope.formatVersion <= VaultEnvelope.currentFormatVersion else { throw VaultSyncPersistenceError.unsupportedFormat(envelope.formatVersion) } guard envelope.isValid else { throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) } - guard let local = entries[envelope.key.recordName]?.envelope else { + guard + let local = entries[envelope.key.recordName]?.envelope?.withoutDeviceLocalMetadata + else { result.accepted.append(envelope) continue } @@ -240,7 +265,7 @@ public final class VaultSyncJournal { public func acceptRemote(_ envelopes: [VaultEnvelope]) throws { var entries = try entriesByName() for envelope in envelopes { - try store(envelope, needsUpload: false, in: &entries) + try store(envelope.withoutDeviceLocalMetadata, needsUpload: false, in: &entries) } } @@ -250,10 +275,12 @@ public final class VaultSyncJournal { } public func markUploaded(_ key: VaultRecordKey, version: VaultVersion) throws { - guard let row = try entry(recordName: key.recordName), row.envelope?.version == version else { + guard let row = try entry(recordName: key.recordName), let envelope = row.envelope, + envelope.version == version + else { return } - row.needsUpload = false + row.apply(envelope.withoutDeviceLocalMetadata, needsUpload: false) } public func updateSystemFields(_ data: Data?, for key: VaultRecordKey) throws { @@ -302,7 +329,10 @@ public final class VaultSyncJournal { for (key, payload) in current { let row = entries[key.recordName] - guard row?.envelope?.payload != payload || row?.envelope?.isDeleted != false else { + guard + row?.envelope?.withoutDeviceLocalMetadata.payload != payload + || row?.envelope?.isDeleted != false + else { continue } let envelope = VaultEnvelope( diff --git a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift index 4bcfea06..632a7b99 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift @@ -51,6 +51,20 @@ public final class HostPersistence { } } + public func saveIdentityLastUsedAt(_ updates: [Identity.ID: Date]) throws { + guard !updates.isEmpty else { return } + do { + for row in try context.fetch(FetchDescriptor()) { + guard let date = updates[row.id], row.lastUsedAt.map({ $0 >= date }) != true else { continue } + row.lastUsedAt = date + } + if context.hasChanges { try context.save() } + } catch { + context.rollback() + throw error + } + } + public func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult { do { let journal = VaultSyncJournal(context: context) diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 70cd6199..4c07748d 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -97,12 +97,14 @@ authentication; callers never perform a separate discovery connection. Generation tokens prevent callbacks, credential results, and retry timers from a retired connection mutating its replacement. -An Identity's persisted `lastUsedAt` means its saved password or private key was +An Identity's device-local `lastUsedAt` means its saved password or private key was successfully read for connection setup, not that remote authentication succeeded. Connection-specific resolvers batch those accesses across destinations, jump hosts, and authenticated proxies; missing secrets and ordinary Keychain availability or -editing reads never update usage. Later transport failure does not roll the access -back, while pooled or borrowed live connections do not reread or refresh it. +editing reads never update usage. The first access for each Identity in an app process +is written directly to its SwiftData row without entering the Vault journal or repository +refresh path. Later retries are deduplicated, and Vault sync strips this local metadata +while preserving it when applying remote Identity configuration. The SSH worker's serial queue owns PTY dimensions. It retains the latest valid viewport during authentication and uses it for the initial PTY request, including diff --git a/TFMTests/SSHImportTests.swift b/TFMTests/SSHImportTests.swift index 53e6941f..968ca99d 100644 --- a/TFMTests/SSHImportTests.swift +++ b/TFMTests/SSHImportTests.swift @@ -565,6 +565,9 @@ struct SSHImportTests { private final class FailingImportPersistence: VaultPersisting { func load() throws -> VaultSnapshot { VaultSnapshot() } func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet { throw CocoaError(.fileWriteUnknown) } + func saveIdentityLastUsedAt(_ updates: [Identity.ID: Date]) throws { + throw CocoaError(.fileWriteUnknown) + } func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult { throw CocoaError(.fileWriteUnknown) } diff --git a/TFMTests/ServerMetricsTests.swift b/TFMTests/ServerMetricsTests.swift index 0affbdda..ed2b03d0 100644 --- a/TFMTests/ServerMetricsTests.swift +++ b/TFMTests/ServerMetricsTests.swift @@ -499,6 +499,24 @@ struct ServerMetricsLifecycleTests { await eventually { fixture.time.delays.contains(8) } } + @Test + func recordingCredentialAccessDoesNotRestartConnection() async throws { + let fixture = try Fixture() + defer { fixture.model.shutdown() } + fixture.model.add(fixture.host) + let server = try #require(fixture.model.servers.first) + await eventually { server.point != nil } + await eventually { fixture.hosts.identity(for: fixture.host)?.lastUsedAt != nil } + + fixture.model.reconcile() + fixture.model.reconcile() + + #expect(!server.isConnecting) + #expect(server.collectionState == .live) + #expect(await fixture.sampler.connections == 1) + #expect(await fixture.sampler.disconnections == 0) + } + @Test func changingHostConfigurationRestartsConnection() async throws { let fixture = try Fixture() diff --git a/TFMTests/VaultSyncTests.swift b/TFMTests/VaultSyncTests.swift index df39a7a6..1a0e5303 100644 --- a/TFMTests/VaultSyncTests.swift +++ b/TFMTests/VaultSyncTests.swift @@ -167,6 +167,138 @@ struct VaultSyncTests { #expect(tombstone.payload == nil) } + @Test + func identityUsageStaysLocalAndDoesNotCreateVaultMutations() throws { + let container = try makeContainer("LocalIdentityUsage") + let context = container.mainContext + let persistence = HostPersistence(context: context) + let identity = Identity(name: "Deploy", username: "deploy") + let key = VaultRecordKey(kind: .identity, id: identity.id) + _ = try persistence.save(VaultSnapshot(identities: [identity])) + let journal = VaultSyncJournal(context: context) + let initial = try #require(try journal.envelope(for: key)) + try journal.markUploaded(key, version: initial.version) + try context.save() + + let usedAt = Date(timeIntervalSince1970: 500) + try persistence.saveIdentityLastUsedAt([identity.id: usedAt]) + + #expect(try persistence.load().identities.first?.lastUsedAt == usedAt) + #expect(try journal.pendingEnvelopes().isEmpty) + #expect(try journal.envelope(for: key)?.version == initial.version) + if case .identity(let payload) = try journal.envelope(for: key)?.payload { + #expect(payload.lastUsedAt == nil) + } else { + Issue.record("Expected an Identity payload") + } + } + + @Test + func lastUsedOnlySnapshotSaveDoesNotAdvanceIdentityEnvelope() throws { + let container = try makeContainer("LastUsedOnlySnapshot") + let context = container.mainContext + let persistence = HostPersistence(context: context) + var identity = Identity(name: "Deploy", username: "deploy") + let key = VaultRecordKey(kind: .identity, id: identity.id) + _ = try persistence.save(VaultSnapshot(identities: [identity])) + let initial = try #require(try VaultSyncJournal(context: context).envelope(for: key)) + identity.lastUsedAt = Date(timeIntervalSince1970: 800) + + let mutations = try persistence.save(VaultSnapshot(identities: [identity])) + + #expect(mutations.isEmpty) + #expect(try VaultSyncJournal(context: context).envelope(for: key)?.version == initial.version) + #expect(try persistence.load().identities.first?.lastUsedAt == identity.lastUsedAt) + } + + @Test + func remoteIdentityPreservesLocalUsageAndStillAppliesConfiguration() throws { + let container = try makeContainer("RemoteIdentityUsage") + let context = container.mainContext + let persistence = HostPersistence(context: context) + let identity = Identity(name: "Deploy", username: "deploy") + let key = VaultRecordKey(kind: .identity, id: identity.id) + _ = try persistence.save(VaultSnapshot(identities: [identity])) + let localEnvelope = try #require(try VaultSyncJournal(context: context).envelope(for: key)) + let localUsage = Date(timeIntervalSince1970: 400) + try persistence.saveIdentityLastUsedAt([identity.id: localUsage]) + + var remoteIdentity = identity + remoteIdentity.name = "Remote Deploy" + remoteIdentity.lastUsedAt = Date(timeIntervalSince1970: 900) + let remote = VaultEnvelope( + key: key, + version: VaultVersion( + modifiedAt: localEnvelope.version.modifiedAt.addingTimeInterval(10), + counter: 0, writerID: UUID() + ), + payload: .identity(remoteIdentity) + ) + + let result = try persistence.applyRemote([remote]) + + #expect(result.snapshot.identities.first?.name == remoteIdentity.name) + #expect(result.snapshot.identities.first?.lastUsedAt == localUsage) + if case .identity(let payload) = try VaultSyncJournal(context: context).envelope(for: key)?.payload { + #expect(payload.lastUsedAt == nil) + } else { + Issue.record("Expected an Identity payload") + } + } + + @Test + func legacyPendingIdentityUsageIsRemovedBeforeUpload() throws { + let container = try makeContainer("LegacyPendingIdentityUsage") + let context = container.mainContext + let identity = Identity( + name: "Deploy", username: "deploy", + lastUsedAt: Date(timeIntervalSince1970: 700) + ) + let envelope = VaultEnvelope( + key: VaultRecordKey(kind: .identity, id: identity.id), + version: .baseline(writerID: UUID()), payload: .identity(identity) + ) + context.insert(try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true)) + try context.save() + + let journal = VaultSyncJournal(context: context) + let outgoing = try #require(try journal.outgoingEntry(for: envelope.key)) + if case .identity(let payload) = outgoing.envelope.payload { + #expect(payload.lastUsedAt == nil) + } else { + Issue.record("Expected an Identity payload") + } + #expect(try journal.pendingEnvelopes() == [outgoing.envelope]) + } + + @Test + func identityConfigurationEditsStillCreateVaultMutations() throws { + let container = try makeContainer("IdentityConfigurationEdit") + let persistence = HostPersistence(context: container.mainContext) + var identity = Identity( + name: "Deploy", username: "deploy", + lastUsedAt: Date(timeIntervalSince1970: 100) + ) + _ = try persistence.save(VaultSnapshot(identities: [identity])) + identity.name = "Production Deploy" + identity.lastUsedAt = Date(timeIntervalSince1970: 200) + + let mutations = try persistence.save(VaultSnapshot(identities: [identity])) + + #expect(mutations.keys == [VaultRecordKey(kind: .identity, id: identity.id)]) + let envelope = try #require( + try VaultSyncJournal(context: container.mainContext).envelope( + for: VaultRecordKey(kind: .identity, id: identity.id) + ) + ) + if case .identity(let payload) = envelope.payload { + #expect(payload.name == identity.name) + #expect(payload.lastUsedAt == nil) + } else { + Issue.record("Expected an Identity payload") + } + } + @Test func newerRemoteRecordWinsAndOlderRemoteRecordIsRequeued() throws { let container = try makeContainer("RemoteMerge") diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index b3628c56..7a4aec9c 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -2246,7 +2246,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 21; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = YES; @@ -2283,7 +2283,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 21; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = YES;