diff --git a/Yattee/Core/AccountRemoval.swift b/Yattee/Core/AccountRemoval.swift index f96521a8..98b4cb24 100644 --- a/Yattee/Core/AccountRemoval.swift +++ b/Yattee/Core/AccountRemoval.swift @@ -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) diff --git a/Yattee/Core/AccountsManager.swift b/Yattee/Core/AccountsManager.swift index c5cb0e57..9cff68aa 100644 --- a/Yattee/Core/AccountsManager.swift +++ b/Yattee/Core/AccountsManager.swift @@ -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() diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index 6cf48f11..f48619f4 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -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) diff --git a/Yattee/Services/CloudKit/AccountSyncCoordinator.swift b/Yattee/Services/CloudKit/AccountSyncCoordinator.swift index 78cdab7b..8eb9e534 100644 --- a/Yattee/Services/CloudKit/AccountSyncCoordinator.swift +++ b/Yattee/Services/CloudKit/AccountSyncCoordinator.swift @@ -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: diff --git a/YatteeTests/AccountSyncTests.swift b/YatteeTests/AccountSyncTests.swift index a54c8dc6..800cf595 100644 --- a/YatteeTests/AccountSyncTests.swift +++ b/YatteeTests/AccountSyncTests.swift @@ -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() diff --git a/YatteeTests/AccountsManagerTests.swift b/YatteeTests/AccountsManagerTests.swift index 0bb261e1..27aab0b6 100644 --- a/YatteeTests/AccountsManagerTests.swift +++ b/YatteeTests/AccountsManagerTests.swift @@ -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") diff --git a/YatteeTests/AccountsScreenTests.swift b/YatteeTests/AccountsScreenTests.swift index d23fdae6..c2870870 100644 --- a/YatteeTests/AccountsScreenTests.swift +++ b/YatteeTests/AccountsScreenTests.swift @@ -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 { [] } + 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")