Refine Identity sync

This commit is contained in:
Kaede Akino committed 2026-09-15 23:24:37 +08:00
1 parent fae324d00e
commit 71d92a384f
12 files changed
+372 -32

No files matched your search

@@ -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: [])
}
@@ -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<Identity.ID> = []
/// 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<Identity.ID>, 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<Identity.ID> = []
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
@@ -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())
@@ -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]
}
@@ -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]
}
@@ -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<PersistentVaultSyncEntry>(
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(
@@ -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<PersistentIdentity>()) {
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)
+5 -3
View File
@@ -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
+3
View File
@@ -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)
}
+18
View File
@@ -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()
+132
View File
@@ -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")
+2 -2
View File
@@ -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;