mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Keep an automatic repair from ever reaching the server
A repair carries the timestamp of the selection it replaced, which is what stops it outranking a deliberate choice made elsewhere — but it also makes every ordering comparison read it as that choice. A fetch holding the selected account's tombstone and an older server context applied them in that order: the tombstone repaired the selection, and the older context then lost to it, so the "the server holds the stale copy" path marked the repair dirty and uploaded an automatic fallback under the deleted choice's timestamp, over an explicit choice. The stored context now remembers whether it is a repair, and a repair that wins an ordering pushes nothing back: the server's context stands remotely, the fallback stays local, and the next explicit choice on any device re-converges everyone. The bit is persisted with the context, or a relaunch would forget and publish it on the first stale record it read. Three more from the same trace: - The repair prefers a signed-in login on the same server, decided off statuses, and it commits — once the selection no longer names the deleted account there is nothing left for a later status to move. Foreground activation refreshes statuses ahead of it, but a deletion delivered by a silent push arrives outside that sequence, and a credential that reached this device over iCloud Keychain while it was in the background announces nothing. The refresh is now asked for by the repair itself, which closes every entry point rather than each fetch trigger having to remember one. - A device that meets a tombstone before the account it kills cannot record which instance that account lived on, and the account record arriving afterwards was discarded — with the only copy of that fact still in it. Every later repair of a context naming it then fell to local past a usable sibling. The association is kept as the record is dropped. - Removing an orphaned account deleted its credential before recording the deletion, so a crash in between brought the account back on the next launch as one nobody can sign into, owing no deletion to any device. The outbox goes first, here and in the sign-out and instance cascades; the scoped credential key needs only the account's ID, and the store is resolved through the instance before anything is removed.
This commit is contained in:
@@ -30,11 +30,19 @@ enum AccountRemoval {
|
||||
accountsManager: AccountsManager,
|
||||
discardData: (UUID) -> Void
|
||||
) {
|
||||
// The deletion goes into the durable outbox first, for the same reason
|
||||
// `removeAccount` records it before anything else: a crash after the
|
||||
// credential was deleted but before the tombstone existed would bring
|
||||
// the account back on the next launch as one nobody can sign into, with
|
||||
// no deletion owed to any other device. Nothing here needs the account
|
||||
// to still exist — the scoped credential key is built from the ID alone,
|
||||
// which is the whole reason an orphan is removed this way.
|
||||
accountsManager.removeAccount(accountID)
|
||||
|
||||
for store in stores {
|
||||
store.deleteCredential(forAccountID: accountID)
|
||||
}
|
||||
|
||||
accountsManager.removeAccount(accountID)
|
||||
// Whatever the account had in memory goes with it, or its subscriptions
|
||||
// and playlists would keep answering for an account nobody has.
|
||||
discardData(accountID)
|
||||
|
||||
@@ -75,6 +75,17 @@ final class AccountsManager {
|
||||
/// Whether the selected context still has to reach the server.
|
||||
private(set) var pendingContextUpload = false
|
||||
|
||||
/// Whether `storedContext` holds an automatic repair rather than a choice.
|
||||
///
|
||||
/// A repair keeps the timestamp of the selection it replaced, so every
|
||||
/// ordering comparison reads it as that choice — including the one that
|
||||
/// decides an older server copy has to be corrected. Without this the
|
||||
/// fallback would be pushed back over a deliberate choice another device
|
||||
/// made, which is exactly what not stamping it "now" is meant to prevent.
|
||||
/// Persisted beside the context, or a relaunch would forget and publish it
|
||||
/// on the next stale record it saw.
|
||||
private(set) var storedContextIsRepair = false
|
||||
|
||||
/// Instances the user has deleted here.
|
||||
///
|
||||
/// Deleting an instance deletes the accounts on it, but those accounts may
|
||||
@@ -331,6 +342,7 @@ final class AccountsManager {
|
||||
context: repairTarget(onInstance: instanceID),
|
||||
changedAt: storedContext.changedAt
|
||||
)
|
||||
storedContextIsRepair = true
|
||||
// Including any upload the deleted choice had queued: sending it now
|
||||
// would publish this fallback under that choice's timestamp, which is
|
||||
// exactly what not stamping it "now" is meant to prevent.
|
||||
@@ -380,6 +392,15 @@ final class AccountsManager {
|
||||
private func repairTarget(onInstance instanceID: UUID?) -> DataContext {
|
||||
guard let instanceID else { return .local }
|
||||
|
||||
// Asked here rather than at the call sites because a repair commits:
|
||||
// once the selection no longer names the deleted account there is
|
||||
// nothing left for a later status to move. A deletion delivered by a
|
||||
// silent push arrives without any of the events that recompute statuses
|
||||
// — a credential can reach this device over iCloud Keychain while it is
|
||||
// in the background — so the sibling would be skipped as signed out
|
||||
// while its credential sat right there.
|
||||
refreshStatusesForRepair?()
|
||||
|
||||
let sibling = accounts(forInstance: instanceID)
|
||||
.filter { status(for: $0.id) == .signedIn }
|
||||
.filter { capabilities(for: $0)?.isSelectableContext == true }
|
||||
@@ -391,6 +412,16 @@ final class AccountsManager {
|
||||
return .account(sibling.id)
|
||||
}
|
||||
|
||||
/// Recomputes account statuses from the Keychain, just before a repair
|
||||
/// decides which sibling — if any — it may land on.
|
||||
///
|
||||
/// A hook rather than a dependency for the same reason as
|
||||
/// `onSuspectedCredentialRejection`: reading the Keychain needs the
|
||||
/// credential stores, and this manager deliberately knows nothing about
|
||||
/// them. `nil` — tests, and the window before wiring completes — simply
|
||||
/// leaves the statuses as they are, which is what the repair did before.
|
||||
@ObservationIgnored var refreshStatusesForRepair: (@MainActor () -> Void)?
|
||||
|
||||
/// Marks a deletion as confirmed by the server. Until then it stays in the
|
||||
/// outbox and is retried, because a tombstone that never lands lets a stale
|
||||
/// device resurrect the account.
|
||||
@@ -437,6 +468,29 @@ final class AccountsManager {
|
||||
deletedAccountIDs.contains(accountID)
|
||||
}
|
||||
|
||||
/// Remembers which instance a deleted account lived on, learned from a
|
||||
/// record that arrived after its tombstone.
|
||||
///
|
||||
/// A device that meets the tombstone first — a fresh install, or any fetch
|
||||
/// that stages both — has no account to read the instance off, and the
|
||||
/// account record that follows is dominated and discarded. That record is
|
||||
/// the only thing that still knows where the account lived, and without it
|
||||
/// every later repair of a context naming it falls to local past a
|
||||
/// perfectly good sibling on the same server.
|
||||
func recordDeletedAccountInstance(_ instanceID: UUID, for accountID: UUID) {
|
||||
// The namespace moved and the reload has not refilled memory yet, so
|
||||
// this would file one identity's deletion under another's.
|
||||
guard loadedNamespace == namespace else { return }
|
||||
guard deletedAccountIDs.contains(accountID) else { return }
|
||||
// The deletion this device performed itself read the instance off the
|
||||
// account it removed, which is closer to the truth than a record that
|
||||
// may have been resurrected since.
|
||||
guard deletedAccountInstanceIDs[accountID] == nil else { return }
|
||||
|
||||
deletedAccountInstanceIDs[accountID] = instanceID
|
||||
save()
|
||||
}
|
||||
|
||||
/// Marks every account record dirty again.
|
||||
///
|
||||
/// For a server that holds nothing — sync being enabled for the first time,
|
||||
@@ -448,7 +502,11 @@ final class AccountsManager {
|
||||
pendingUploads.formUnion(accounts.map(\.id))
|
||||
pendingMarkerUploads.formUnion(migratedInstanceIDs)
|
||||
pendingDeletions.formUnion(deletedAccountIDs)
|
||||
pendingContextUpload = hasStoredContext
|
||||
// An empty zone is no reason to publish a fallback nobody chose: a
|
||||
// repair is never uploaded, however little the server holds. The next
|
||||
// explicit choice fills the gap, and until then every device keeps
|
||||
// repairing to whatever it can actually use.
|
||||
pendingContextUpload = hasStoredContext && !storedContextIsRepair
|
||||
save()
|
||||
}
|
||||
|
||||
@@ -481,8 +539,13 @@ final class AccountsManager {
|
||||
@discardableResult
|
||||
func applySyncedAccount(_ incoming: Account) -> Bool {
|
||||
// A tombstone dominates the account everywhere. The caller repairs the
|
||||
// server copy; here it simply never becomes local state again.
|
||||
guard !deletedAccountIDs.contains(incoming.id) else { return false }
|
||||
// server copy; here it simply never becomes local state again — except
|
||||
// for the one thing worth keeping: which instance it lived on, for a
|
||||
// context repair that has no account left to read it off.
|
||||
guard !deletedAccountIDs.contains(incoming.id) else {
|
||||
recordDeletedAccountInstance(incoming.instanceID, for: incoming.id)
|
||||
return false
|
||||
}
|
||||
|
||||
// Its instance was deleted here while this account was still in
|
||||
// transit. Deleting an instance deletes its accounts, so this one is
|
||||
@@ -606,6 +669,8 @@ final class AccountsManager {
|
||||
func setContext(_ context: DataContext) {
|
||||
storedContext = StoredDataContext(context: context)
|
||||
hasStoredContext = true
|
||||
// Whatever stood here before, this is a choice — and choices publish.
|
||||
storedContextIsRepair = false
|
||||
pendingContextUpload = true
|
||||
save()
|
||||
NotificationCenter.default.post(name: .dataContextDidChange, object: nil)
|
||||
@@ -659,6 +724,13 @@ final class AccountsManager {
|
||||
// repair that lost the ordering never gets to clear what that
|
||||
// choice still owes the server.
|
||||
guard incoming.isOutranked(by: storedContext) else { return false }
|
||||
// Newer, but not a choice. A repair carries the replaced selection's
|
||||
// timestamp, so it outranks the server's older explicit value while
|
||||
// having nothing of its own to say; publishing it would push an
|
||||
// automatic fallback over that value. Nothing is uploaded here: the
|
||||
// server's context stands remotely, the repair stays local, and the
|
||||
// next explicit choice on any device re-converges everyone.
|
||||
guard !storedContextIsRepair else { return false }
|
||||
pendingContextUpload = true
|
||||
save()
|
||||
return true
|
||||
@@ -677,6 +749,9 @@ final class AccountsManager {
|
||||
private func adoptSyncedContext(_ incoming: StoredDataContext, wasRepaired: Bool) {
|
||||
storedContext = incoming
|
||||
hasStoredContext = true
|
||||
// A value adopted as it stands is somebody's choice and may be pushed
|
||||
// back if the server ever holds an older one; a repaired one may not.
|
||||
storedContextIsRepair = wasRepaired
|
||||
if wasRepaired {
|
||||
pendingContextUpload = false
|
||||
}
|
||||
@@ -903,6 +978,7 @@ final class AccountsManager {
|
||||
|
||||
storedContext = .migrated(context)
|
||||
hasStoredContext = true
|
||||
storedContextIsRepair = false
|
||||
// Published like any other context, but stamped at the epoch: if a real
|
||||
// choice exists anywhere it outranks this one whichever arrives first.
|
||||
pendingContextUpload = true
|
||||
@@ -1019,6 +1095,7 @@ final class AccountsManager {
|
||||
}
|
||||
|
||||
pendingContextUpload = defaults.bool(forKey: key("pendingContextUpload"))
|
||||
storedContextIsRepair = defaults.bool(forKey: key("contextIsRepair"))
|
||||
|
||||
purgeUnresolvedNamespace()
|
||||
}
|
||||
@@ -1027,7 +1104,8 @@ final class AccountsManager {
|
||||
private static let storedKeyNames = [
|
||||
"accounts", "context", "hasContext", "migrated", "pendingUploads",
|
||||
"pendingDeletions", "pendingMarkerUploads", "deletedAccounts",
|
||||
"deletedInstances", "deletedAccountInstances", "pendingContextUpload"
|
||||
"deletedInstances", "deletedAccountInstances", "pendingContextUpload",
|
||||
"contextIsRepair"
|
||||
]
|
||||
|
||||
/// Deletes whatever was written while the identity was still unresolved.
|
||||
@@ -1085,6 +1163,7 @@ final class AccountsManager {
|
||||
forKey: key("deletedAccountInstances")
|
||||
)
|
||||
defaults.set(pendingContextUpload, forKey: key("pendingContextUpload"))
|
||||
defaults.set(storedContextIsRepair, forKey: key("contextIsRepair"))
|
||||
}
|
||||
|
||||
/// Re-reads storage under the current identity namespace.
|
||||
@@ -1109,6 +1188,7 @@ final class AccountsManager {
|
||||
deletedInstanceIDs = []
|
||||
deletedAccountInstanceIDs = [:]
|
||||
pendingContextUpload = false
|
||||
storedContextIsRepair = false
|
||||
|
||||
load()
|
||||
reconcileLegacyAccounts()
|
||||
|
||||
@@ -493,6 +493,18 @@ final class AppEnvironment {
|
||||
// A deletion that arrives from another device takes the credential with
|
||||
// it. Only a deletion does this — an account merely missing from a
|
||||
// fetch keeps everything, because a fetch is not an enumeration.
|
||||
// A context repair reads statuses and commits — there is no second
|
||||
// chance at it once the selection no longer names the deleted account —
|
||||
// and the deletion that triggers one can arrive at any moment: a silent
|
||||
// push handled in the background, long after anything last recomputed
|
||||
// them. Foreground activation sequences the refresh ahead of the repair
|
||||
// for exactly this reason; wiring it into the repair itself closes every
|
||||
// other entry point too, instead of each fetch trigger having to
|
||||
// remember the order.
|
||||
accounts.refreshStatusesForRepair = { [weak self] in
|
||||
self?.refreshAccountStatuses()
|
||||
}
|
||||
|
||||
accountSync.onAccountDeleted = { [weak self] accountID, account in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -868,11 +880,15 @@ final class AppEnvironment {
|
||||
accountsManager.recordInstanceDeletion(instance.id)
|
||||
|
||||
for account in accountsManager.accounts(forInstance: instance.id) {
|
||||
// Resolved while the instance is still registered — it is removed
|
||||
// below — and the account's own deletion recorded before its secret
|
||||
// is touched, so a crash in between cannot leave a signed-out
|
||||
// account behind with no deletion owed to anybody.
|
||||
let credentials = accountCredentialsManager(for: account)
|
||||
accountsManager.removeAccount(account.id)
|
||||
credentials?.deleteCredential(for: account)
|
||||
credentials?.deleteLegacyCredentialIfOwned(for: account)
|
||||
discardAccountData(for: account.id)
|
||||
accountsManager.removeAccount(account.id)
|
||||
}
|
||||
|
||||
instancesManager.remove(instance)
|
||||
@@ -884,12 +900,21 @@ final class AppEnvironment {
|
||||
/// cleared is added here rather than in each screen that lists accounts.
|
||||
func signOutAndRemoveAccount(_ account: Account) {
|
||||
let instance = accountsManager.instance(for: account)
|
||||
// Resolved before the account is removed, though nothing about the
|
||||
// lookup needs it to still be there: the store is chosen by the
|
||||
// instance's type, and the instance is not going anywhere here.
|
||||
let credentials = accountCredentialsManager(for: account)
|
||||
|
||||
// The tombstone and its outbox entry go first. A crash after the
|
||||
// credential was deleted but before the deletion was recorded would
|
||||
// otherwise leave the account standing on the next launch — signed out,
|
||||
// and with nothing owed to the devices that still have it.
|
||||
accountsManager.removeAccount(account.id)
|
||||
|
||||
// Deleting the account is what authorizes removing the legacy item, and
|
||||
// only when this device adopted it and it still holds that same secret.
|
||||
accountCredentialsManager(for: account)?.deleteCredential(for: account)
|
||||
accountCredentialsManager(for: account)?.deleteLegacyCredentialIfOwned(for: account)
|
||||
accountsManager.removeAccount(account.id)
|
||||
credentials?.deleteCredential(for: account)
|
||||
credentials?.deleteLegacyCredentialIfOwned(for: account)
|
||||
// Drop what the account had in memory too, or its subscriptions and
|
||||
// playlists would keep answering for an account that no longer exists.
|
||||
discardAccountData(for: account.id)
|
||||
|
||||
@@ -496,6 +496,11 @@ final class AccountSyncCoordinator {
|
||||
// tombstone ignores it locally and deletes it again server-side; the
|
||||
// window is bounded by fetch propagation, not by trust.
|
||||
guard !accountsManager.isDeleted(account.id) else {
|
||||
// The record is discarded, but not before it says where the account
|
||||
// lived: a device that met the tombstone first has no other source
|
||||
// for that, and a context repair without it lands on local past a
|
||||
// signed-in sibling on the same server.
|
||||
accountsManager.recordDeletedAccountInstance(account.instanceID, for: account.id)
|
||||
LoggingService.shared.logCloudKit("Ignoring resurrected account \(account.id) and re-deleting it")
|
||||
enqueueChanges?([.deleteRecord(recordID(for: .account(id: account.id)))])
|
||||
return
|
||||
@@ -868,8 +873,14 @@ final class AccountSyncCoordinator {
|
||||
|
||||
switch resolved.recordType {
|
||||
case RecordType.account:
|
||||
guard let account = try? mapper.toAccount(from: resolved),
|
||||
!accountsManager.isDeleted(account.id) else { return }
|
||||
guard let account = try? mapper.toAccount(from: resolved) else { return }
|
||||
// Dominated by a tombstone, so nothing of it becomes local state —
|
||||
// beyond the instance it lived on, which a later context repair has
|
||||
// no other way to learn.
|
||||
guard !accountsManager.isDeleted(account.id) else {
|
||||
accountsManager.recordDeletedAccountInstance(account.instanceID, for: account.id)
|
||||
return
|
||||
}
|
||||
accountsManager.applySyncedAccount(account)
|
||||
|
||||
case RecordType.dataContext:
|
||||
|
||||
@@ -689,6 +689,78 @@ struct AccountSyncTests {
|
||||
#expect(harness.accounts.storedContext.changedAt == stale.changedAt)
|
||||
}
|
||||
|
||||
@Test("A repair made mid-batch is never uploaded over the server's older context")
|
||||
func repairIsNotPublishedForAStaleServerContext() async {
|
||||
let instances = InstancesManager()
|
||||
let instance = Instance(type: .invidious, url: URL(string: "https://inv-\(UUID().uuidString).example.com")!)
|
||||
instances.add(instance)
|
||||
defer { instances.remove(instance) }
|
||||
|
||||
let harness = Self.makeHarness(instances: instances)
|
||||
let selected = harness.accounts.addAccount(instanceID: instance.id)
|
||||
let sibling = harness.accounts.addAccount(instanceID: instance.id)
|
||||
harness.accounts.setStatus(.signedIn, for: sibling.id)
|
||||
// Both accounts are on the server already, so nothing holds the context
|
||||
// back for ordering reasons.
|
||||
harness.accounts.confirmUpload(selected.id)
|
||||
harness.accounts.confirmUpload(sibling.id)
|
||||
|
||||
harness.accounts.setContext(.account(selected.id))
|
||||
let chosenAt = harness.accounts.storedContext.changedAt
|
||||
harness.accounts.confirmContextUpload(changedAt: chosenAt)
|
||||
|
||||
// Tombstones are applied first, so the repair is in place — carrying the
|
||||
// deleted choice's timestamp — by the time the older context is weighed
|
||||
// against it.
|
||||
let older = StoredDataContext(context: .local, changedAt: chosenAt.addingTimeInterval(-60))
|
||||
harness.coordinator.stageFetched(
|
||||
records: [
|
||||
harness.mapper.toCKRecord(accountDeletionID: selected.id),
|
||||
harness.mapper.toCKRecord(dataContext: older)
|
||||
],
|
||||
deletions: []
|
||||
)
|
||||
harness.recorder.clear()
|
||||
await harness.coordinator.activate()
|
||||
|
||||
#expect(harness.accounts.currentContext == .account(sibling.id))
|
||||
#expect(!harness.recorder.savedNames.contains("datacontext-current"))
|
||||
#expect(harness.coordinator.recordToSave(named: "datacontext-current") == nil)
|
||||
}
|
||||
|
||||
@Test("An account discarded for its tombstone still repairs the context")
|
||||
func discardedAccountKeepsItsInstance() async {
|
||||
let instances = InstancesManager()
|
||||
let instance = Instance(type: .invidious, url: URL(string: "https://inv-\(UUID().uuidString).example.com")!)
|
||||
instances.add(instance)
|
||||
defer { instances.remove(instance) }
|
||||
|
||||
let harness = Self.makeHarness(instances: instances)
|
||||
let sibling = harness.accounts.addAccount(instanceID: instance.id)
|
||||
harness.accounts.setStatus(.signedIn, for: sibling.id)
|
||||
|
||||
// A fresh device meets all three at once: the tombstone runs first,
|
||||
// while the account it kills is still unknown here, and the record that
|
||||
// follows is the only thing that says which server it lived on.
|
||||
let deleted = Account(instanceID: instance.id)
|
||||
harness.coordinator.stageFetched(
|
||||
records: [
|
||||
harness.mapper.toCKRecord(accountDeletionID: deleted.id),
|
||||
harness.mapper.toCKRecord(account: deleted),
|
||||
harness.mapper.toCKRecord(
|
||||
dataContext: StoredDataContext(context: .account(deleted.id), changedAt: Date())
|
||||
)
|
||||
],
|
||||
deletions: []
|
||||
)
|
||||
await harness.coordinator.activate()
|
||||
|
||||
#expect(harness.accounts.deletedAccountInstanceIDs[deleted.id] == instance.id)
|
||||
// Repaired onto the login the user can actually use, rather than falling
|
||||
// all the way back to local because the metadata was thrown away.
|
||||
#expect(harness.accounts.currentContext == .account(sibling.id))
|
||||
}
|
||||
|
||||
@Test("Records fetched before the identity resolves are recovered by a bootstrap")
|
||||
func unresolvedIdentityForcesAnotherBootstrap() async {
|
||||
let defaults = Self.makeDefaults()
|
||||
|
||||
@@ -743,11 +743,14 @@ struct DataContextRepairTests {
|
||||
Instance(type: .invidious, url: URL(string: "https://\(host)-\(UUID().uuidString).example.com")!)
|
||||
}
|
||||
|
||||
private func makeManager(instances: InstancesManager) -> AccountsManager {
|
||||
private func makeManager(
|
||||
instances: InstancesManager,
|
||||
defaults: UserDefaults? = nil
|
||||
) -> AccountsManager {
|
||||
AccountsManager(
|
||||
identityScope: CredentialIdentityScope(fixedScope: "identity-A"),
|
||||
instancesManager: instances,
|
||||
defaults: UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!
|
||||
defaults: defaults ?? UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!
|
||||
)
|
||||
}
|
||||
|
||||
@@ -950,6 +953,113 @@ struct DataContextRepairTests {
|
||||
#expect(manager.currentContext == .local)
|
||||
#expect(manager.pendingContextUpload)
|
||||
}
|
||||
|
||||
@Test("A repair is never pushed back over the server's older context")
|
||||
func repairIsNeverPushedBackOverAnOlderContext() {
|
||||
let instances = InstancesManager()
|
||||
let instance = makeInstance("inv")
|
||||
instances.add(instance)
|
||||
defer { instances.remove(instance) }
|
||||
|
||||
let defaults = UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!
|
||||
let manager = makeManager(instances: instances, defaults: defaults)
|
||||
let selected = manager.addAccount(instanceID: instance.id)
|
||||
let sibling = manager.addAccount(instanceID: instance.id)
|
||||
manager.setStatus(.signedIn, for: sibling.id)
|
||||
|
||||
manager.setContext(.account(selected.id))
|
||||
let chosenAt = manager.storedContext.changedAt
|
||||
manager.confirmContextUpload(changedAt: chosenAt)
|
||||
#expect(!manager.pendingContextUpload)
|
||||
|
||||
// One fetch carrying both: the tombstone repairs the selection, keeping
|
||||
// the deleted choice's timestamp, and the older context the server still
|
||||
// holds arrives right behind it.
|
||||
manager.applyRemoteTombstone(selected.id)
|
||||
#expect(manager.currentContext == .account(sibling.id))
|
||||
|
||||
let older = StoredDataContext(context: .local, changedAt: chosenAt.addingTimeInterval(-60))
|
||||
#expect(!manager.applySyncedContext(older))
|
||||
|
||||
// The repair only outranks the server's copy because it inherited that
|
||||
// timestamp. Publishing it would put a fallback nobody chose over an
|
||||
// explicit choice — the server's context stands, and the next real
|
||||
// selection anywhere re-converges everyone.
|
||||
#expect(manager.currentContext == .account(sibling.id))
|
||||
#expect(manager.storedContext.changedAt == chosenAt)
|
||||
#expect(!manager.pendingContextUpload)
|
||||
|
||||
// Still a repair after a relaunch, or the next stale record read from
|
||||
// disk would publish it.
|
||||
let reloaded = makeManager(instances: instances, defaults: defaults)
|
||||
#expect(!reloaded.applySyncedContext(older))
|
||||
#expect(!reloaded.pendingContextUpload)
|
||||
|
||||
// Choosing it deliberately is a different thing entirely, and publishes.
|
||||
reloaded.setContext(.account(sibling.id))
|
||||
#expect(reloaded.applySyncedContext(older))
|
||||
#expect(reloaded.pendingContextUpload)
|
||||
}
|
||||
|
||||
@Test("A repair recomputes statuses before it picks a sibling")
|
||||
func repairRefreshesStatusesBeforeChoosing() {
|
||||
let instances = InstancesManager()
|
||||
let instance = makeInstance("inv")
|
||||
instances.add(instance)
|
||||
defer { instances.remove(instance) }
|
||||
|
||||
let manager = makeManager(instances: instances)
|
||||
let selected = manager.addAccount(instanceID: instance.id)
|
||||
let sibling = manager.addAccount(instanceID: instance.id)
|
||||
// The sibling's credential arrived over iCloud Keychain while the app
|
||||
// was in the background. Nothing that recomputes statuses ran, so it
|
||||
// still reads as signed out.
|
||||
manager.setStatus(.credentialMissing, for: sibling.id)
|
||||
manager.setContext(.account(selected.id))
|
||||
|
||||
var refreshes = 0
|
||||
manager.refreshStatusesForRepair = { [weak manager] in
|
||||
refreshes += 1
|
||||
manager?.setStatus(.signedIn, for: sibling.id)
|
||||
}
|
||||
|
||||
// A tombstone delivered by a silent push, with no foreground activation
|
||||
// to sequence the refresh ahead of it.
|
||||
manager.applyRemoteTombstone(selected.id)
|
||||
|
||||
#expect(refreshes == 1)
|
||||
#expect(manager.currentContext == .account(sibling.id))
|
||||
}
|
||||
|
||||
@Test("An account dominated by a tombstone still says where it lived")
|
||||
func discardedAccountKeepsItsInstanceForRepair() {
|
||||
let instances = InstancesManager()
|
||||
let instance = makeInstance("inv")
|
||||
instances.add(instance)
|
||||
defer { instances.remove(instance) }
|
||||
|
||||
let manager = makeManager(instances: instances)
|
||||
let sibling = manager.addAccount(instanceID: instance.id)
|
||||
manager.setStatus(.signedIn, for: sibling.id)
|
||||
|
||||
// A device that meets the tombstone before the account it kills has
|
||||
// nothing to read the instance off — the map entry cannot be made then.
|
||||
let deleted = Account(instanceID: instance.id)
|
||||
manager.applyRemoteTombstone(deleted.id)
|
||||
#expect(manager.deletedAccountInstanceIDs[deleted.id] == nil)
|
||||
|
||||
// The dominated record that follows is the only thing that still knows.
|
||||
manager.applySyncedAccount(deleted)
|
||||
#expect(manager.account(id: deleted.id) == nil)
|
||||
#expect(manager.deletedAccountInstanceIDs[deleted.id] == instance.id)
|
||||
|
||||
// Which is what lets the context naming it land on the sibling instead
|
||||
// of falling all the way back to local.
|
||||
manager.applySyncedContext(
|
||||
StoredDataContext(context: .account(deleted.id), changedAt: Date())
|
||||
)
|
||||
#expect(manager.currentContext == .account(sibling.id))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Account capabilities")
|
||||
|
||||
@@ -152,6 +152,35 @@ struct OrphanedAccountRemovalTests {
|
||||
#expect(accountsManager.pendingDeletions.contains(account.id))
|
||||
}
|
||||
|
||||
@Test("The deletion is durable before the secret is destroyed")
|
||||
func removalRecordsTheTombstoneFirst() {
|
||||
let scope = makeScope()
|
||||
let instances = InstancesManager()
|
||||
let instance = makeInstance(.invidious)
|
||||
instances.add(instance)
|
||||
|
||||
let accountsManager = AccountsManager(
|
||||
identityScope: scope,
|
||||
instancesManager: instances,
|
||||
defaults: makeDefaults()
|
||||
)
|
||||
let account = accountsManager.addAccount(instanceID: instance.id)
|
||||
|
||||
instances.remove(instance)
|
||||
|
||||
let store = DeletionOrderStore(accountsManager: accountsManager)
|
||||
AccountRemoval.removeOrphan(
|
||||
account.id,
|
||||
stores: [store],
|
||||
accountsManager: accountsManager,
|
||||
discardData: { _ in }
|
||||
)
|
||||
|
||||
// A crash in between would otherwise bring the account back on the next
|
||||
// launch — signed out, with no deletion owed to any other device.
|
||||
#expect(store.tombstoneWhenDeleted[account.id] == true)
|
||||
}
|
||||
|
||||
@Test("The pre-revamp instance-keyed item is deliberately left alone")
|
||||
func removalLeavesTheLegacyItem() {
|
||||
let scope = makeScope()
|
||||
@@ -186,6 +215,44 @@ struct OrphanedAccountRemovalTests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A credential store that records what the account manager already knew at the
|
||||
/// moment it was asked to destroy a secret.
|
||||
///
|
||||
/// The ordering is the whole point of the cascade and cannot be observed from
|
||||
/// its outcome — both orders end in the same state unless the process dies
|
||||
/// halfway.
|
||||
@Observable
|
||||
@MainActor
|
||||
private final class DeletionOrderStore: AccountCredentialsManager {
|
||||
private let accountsManager: AccountsManager
|
||||
var tombstoneWhenDeleted: [UUID: Bool] = [:]
|
||||
|
||||
init(accountsManager: AccountsManager) {
|
||||
self.accountsManager = accountsManager
|
||||
}
|
||||
|
||||
var loggedInAccountIDs: Set<UUID> { [] }
|
||||
var isScopeResolved: Bool { true }
|
||||
|
||||
func setCredential(_: String, for _: Account) {}
|
||||
func credential(for _: Account) -> String? { nil }
|
||||
func deleteCredential(for account: Account) { deleteCredential(forAccountID: account.id) }
|
||||
|
||||
func deleteCredential(forAccountID accountID: UUID) {
|
||||
tombstoneWhenDeleted[accountID] = accountsManager.isDeleted(accountID)
|
||||
&& accountsManager.pendingDeletions.contains(accountID)
|
||||
}
|
||||
|
||||
func resyncCredentialsWithCurrentSetting() {}
|
||||
func setUsername(_: String, for _: Account) {}
|
||||
func username(for _: Account) -> String? { nil }
|
||||
func isLoggedIn(for _: Account) -> Bool { false }
|
||||
func refreshLoginStatus(for _: Account) {}
|
||||
func hasAdoptableLegacyCredential(for _: Account) -> Bool { false }
|
||||
@discardableResult func adoptLegacyCredential(for _: Account) -> Bool { false }
|
||||
func deleteLegacyCredentialIfOwned(for _: Account) {}
|
||||
}
|
||||
|
||||
@Suite("Instance deletion disclosure")
|
||||
struct InstanceDeletionDisclosureTests {
|
||||
@Test("A source with no accounts adds nothing to the question")
|
||||
|
||||
Reference in New Issue
Block a user