Files
yattee/YatteeTests/LocalNamespaceImportTests.swift
2026-09-08 22:23:30 +02:00

720 lines
31 KiB
Swift

//
// LocalNamespaceImportTests.swift
// YatteeTests
//
// Importing the signed-out account namespace into the Apple account that
// claims it.
//
import Testing
import Foundation
@testable import Yattee
@Suite("Local namespace import")
@MainActor
struct LocalNamespaceImportTests {
private var localScope: String { CredentialIdentityScope.localScope }
// MARK: - Basics
@Test("An account created while signed out follows the identity that signs in")
func accountCredentialAndContextAreImported() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let instance = harness.addInstance()
let account = harness.accounts.addAccount(instanceID: instance.id, displayName: "Main")
harness.credentials.setCredential("secret", for: account)
harness.credentials.setUsername("btest1", for: account)
harness.accounts.setContext(.account(account.id))
defer { harness.cleanUp(account.id) }
try await harness.flip(to: "identity-A")
// The record landed in the identity's namespace, owed to the server.
#expect(harness.accounts.account(id: account.id)?.displayName == "Main")
#expect(harness.accounts.pendingUploads.contains(account.id))
// The credential came with it and reads under the new scope.
#expect(harness.credentials.credential(for: account) == "secret")
#expect(harness.credentials.username(for: account) == "btest1")
#expect(harness.credentials.isLoggedIn(for: account))
// Move semantics: nothing is left behind in either store.
#expect(harness.rawCredential(scope: localScope, accountID: account.id) == nil)
#expect(harness.rawUsername(scope: localScope, accountID: account.id) == nil)
#expect(harness.storedAccounts(in: localScope).isEmpty)
// The selection carried over, epoch-stamped and owed to the server.
#expect(harness.accounts.currentContext == .account(account.id))
#expect(harness.accounts.storedContext.isMigrated)
#expect(harness.accounts.pendingContextUpload)
// Its instance's migration marker travelled with it.
#expect(harness.accounts.migratedInstanceIDs.contains(instance.id))
}
@Test("An account that was never signed in imports and prunes cleanly")
func accountWithoutCredentialImports() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let account = harness.accounts.addAccount(instanceID: UUID(), displayName: "Never signed in")
defer { harness.cleanUp(account.id) }
try await harness.flip(to: "identity-A")
#expect(harness.accounts.account(id: account.id)?.displayName == "Never signed in")
#expect(harness.credentials.credential(for: account) == nil)
#expect(harness.storedAccounts(in: localScope).isEmpty)
}
// MARK: - Collisions
@Test("An account the identity already holds keeps its record and only fills gaps")
func collisionKeepsTheIdentityRecord() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let instanceID = UUID()
let newer = UUID()
let older = UUID()
let alreadySignedIn = UUID()
defer { harness.cleanUp(newer, older, alreadySignedIn) }
// What the identity already knows, from its own devices.
harness.seed("identity-A") { manager in
for id in [newer, older, alreadySignedIn] {
manager.applySyncedAccount(
Account(
id: id,
instanceID: instanceID,
displayName: "Identity",
updatedAt: Date(timeIntervalSince1970: 1000)
)
)
}
}
harness.seedCredential("identity-secret", accountID: alreadySignedIn, scope: "identity-A")
// What the device holds from being signed out.
harness.accounts.applySyncedAccount(
Account(id: newer, instanceID: instanceID, displayName: "Local newer", updatedAt: Date())
)
harness.accounts.applySyncedAccount(
Account(
id: older,
instanceID: instanceID,
displayName: "Local older",
updatedAt: Date(timeIntervalSince1970: 500)
)
)
harness.accounts.applySyncedAccount(
Account(
id: alreadySignedIn,
instanceID: instanceID,
displayName: "Identity",
updatedAt: Date(timeIntervalSince1970: 1000)
)
)
harness.seedCredential("local-secret", accountID: newer, scope: localScope)
harness.seedCredential("local-secret", accountID: alreadySignedIn, scope: localScope)
try await harness.flip(to: "identity-A")
// A strictly newer label is merged; an older one is not.
#expect(harness.accounts.account(id: newer)?.displayName == "Local newer")
#expect(harness.accounts.account(id: older)?.displayName == "Identity")
// A credential is only ever filled in where the identity had none.
#expect(harness.rawCredential(scope: "identity-A", accountID: newer) == "local-secret")
#expect(harness.rawCredential(scope: "identity-A", accountID: alreadySignedIn) == "identity-secret")
// The redundant copies are gone and the local namespace is empty.
#expect(harness.rawCredential(scope: localScope, accountID: newer) == nil)
#expect(harness.rawCredential(scope: localScope, accountID: alreadySignedIn) == nil)
#expect(harness.storedAccounts(in: localScope).isEmpty)
}
@Test("An account this identity deleted is never resurrected from a local copy")
func tombstoneRefusesTheImport() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let deleted = UUID()
let instanceID = UUID()
defer { harness.cleanUp(deleted) }
harness.seed("identity-A") { $0.applyRemoteTombstone(deleted) }
harness.accounts.applySyncedAccount(Account(id: deleted, instanceID: instanceID))
harness.seedCredential("local-secret", accountID: deleted, scope: localScope)
let survivor = harness.accounts.addAccount(instanceID: UUID())
defer { harness.cleanUp(survivor.id) }
try await harness.flip(to: "identity-A")
#expect(harness.accounts.account(id: deleted) == nil)
// Nothing is owed to the server for a record that was refused — and the
// account that *was* imported still is.
#expect(harness.accounts.pendingUploads == [survivor.id])
#expect(harness.accounts.pendingDeletions.isEmpty)
// The secret went with the refused record, under both scopes.
#expect(harness.rawCredential(scope: localScope, accountID: deleted) == nil)
#expect(harness.rawCredential(scope: "identity-A", accountID: deleted) == nil)
#expect(harness.storedAccounts(in: localScope).isEmpty)
}
// MARK: - Keychain failures
@Test("A credential the Keychain refuses to move keeps the account local until it can")
func aFailedCredentialMoveIsRetriedUntilItSettles() async throws {
let flaky = FlakyCredentialsManager()
let harness = Harness(startingScope: CredentialIdentityScope.localScope, credentialStore: flaky)
let account = harness.accounts.addAccount(instanceID: UUID(), displayName: "Main")
defer { harness.cleanUp(account.id) }
try await harness.flip(to: "identity-A")
// The record is in both namespaces: imported under the identity, and
// still local because the secret it names has not moved. That is what
// brings the sweep back — pruning here would orphan `local|<id>` with
// nothing left to say it exists.
#expect(flaky.migrateCalls == 1)
#expect(harness.accounts.account(id: account.id)?.displayName == "Main")
#expect(harness.storedAccounts(in: localScope).map(\.id) == [account.id])
// The retry converges: the collision rule reads the record it imported
// last time as one this identity already holds, so nothing duplicates.
flaky.refusesEverything = false
#expect(harness.importer.sweepIfNeeded())
#expect(flaky.migrateCalls == 2)
#expect(harness.accounts.accounts.map(\.id) == [account.id])
#expect(harness.accounts.pendingUploads == [account.id])
#expect(harness.storedAccounts(in: localScope).isEmpty)
}
@Test("A refused account whose secret cannot be deleted is refused again rather than forgotten")
func aFailedTombstoneDeletionIsRetried() async throws {
let flaky = FlakyCredentialsManager()
let harness = Harness(startingScope: CredentialIdentityScope.localScope, credentialStore: flaky)
let deleted = UUID()
defer { harness.cleanUp(deleted) }
harness.seed("identity-A") { $0.applyRemoteTombstone(deleted) }
harness.accounts.applySyncedAccount(Account(id: deleted, instanceID: UUID()))
try await harness.flip(to: "identity-A")
#expect(flaky.deleteCalls == 1)
#expect(harness.accounts.account(id: deleted) == nil)
#expect(harness.storedAccounts(in: localScope).map(\.id) == [deleted])
flaky.refusesEverything = false
#expect(harness.importer.sweepIfNeeded())
#expect(flaky.deleteCalls == 2)
#expect(harness.accounts.account(id: deleted) == nil)
#expect(harness.accounts.pendingUploads.isEmpty)
#expect(harness.storedAccounts(in: localScope).isEmpty)
}
@Test("A login identifier with no credential beside it still moves before the record is pruned")
func aUsernameWithoutACredentialMoves() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let account = harness.accounts.addAccount(instanceID: UUID(), displayName: "Never signed in")
harness.credentials.setUsername("btest1", for: account)
defer { harness.cleanUp(account.id) }
try await harness.flip(to: "identity-A")
#expect(harness.credentials.username(for: account) == "btest1")
#expect(harness.rawUsername(scope: localScope, accountID: account.id) == nil)
#expect(harness.storedAccounts(in: localScope).isEmpty)
}
@Test("A redundant copy takes its login identifier with it")
func aRedundantCopyClearsTheLocalUsername() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let accountID = UUID()
defer { harness.cleanUp(accountID) }
harness.seed("identity-A") { $0.applySyncedAccount(Account(id: accountID, instanceID: UUID())) }
harness.seedCredential("identity-secret", accountID: accountID, scope: "identity-A")
harness.seedUsername("identity-login", accountID: accountID, scope: "identity-A")
harness.accounts.applySyncedAccount(Account(id: accountID, instanceID: UUID()))
harness.seedCredential("local-secret", accountID: accountID, scope: localScope)
harness.seedUsername("local-login", accountID: accountID, scope: localScope)
try await harness.flip(to: "identity-A")
// The identity's own sign-in wins, and nothing of the redundant copy is
// left under the namespace being abandoned.
#expect(harness.rawCredential(scope: "identity-A", accountID: accountID) == "identity-secret")
#expect(harness.rawUsername(scope: "identity-A", accountID: accountID) == "identity-login")
#expect(harness.rawCredential(scope: localScope, accountID: accountID) == nil)
#expect(harness.rawUsername(scope: localScope, accountID: accountID) == nil)
#expect(harness.storedAccounts(in: localScope).isEmpty)
}
// MARK: - Context
@Test("A context chosen while signed out is dropped when the identity has one")
func identityContextWins() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let account = harness.accounts.addAccount(instanceID: UUID())
defer { harness.cleanUp(account.id) }
harness.accounts.setContext(.account(account.id))
harness.seed("identity-A") { $0.setContext(.local) }
try await harness.flip(to: "identity-A")
#expect(harness.accounts.currentContext == .local)
// The local keys are cleared either way, or every activation would offer
// the same stale selection again.
#expect(harness.defaults.data(forKey: "accounts.local.context") == nil)
#expect(!harness.defaults.bool(forKey: "accounts.local.hasContext"))
}
@Test("A context naming an account the identity refused falls back to local data")
func contextNamingARefusedAccountFallsBack() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let deleted = UUID()
defer { harness.cleanUp(deleted) }
harness.seed("identity-A") { $0.applyRemoteTombstone(deleted) }
harness.accounts.applySyncedAccount(Account(id: deleted, instanceID: UUID()))
harness.accounts.setContext(.account(deleted))
try await harness.flip(to: "identity-A")
#expect(harness.accounts.account(id: deleted) == nil)
#expect(harness.accounts.currentContext == .local)
#expect(harness.accounts.storedContext.isMigrated)
}
// MARK: - Repetition
@Test("Sweeping again changes nothing and duplicates nothing")
func sweepIsIdempotent() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let account = harness.accounts.addAccount(instanceID: UUID(), displayName: "Main")
harness.credentials.setCredential("secret", for: account)
harness.accounts.setContext(.account(account.id))
defer { harness.cleanUp(account.id) }
try await harness.flip(to: "identity-A")
let accountsAfterFirst = harness.accounts.accounts
let uploadsAfterFirst = harness.accounts.pendingUploads
let contextAfterFirst = harness.accounts.storedContext
#expect(harness.importer.sweepIfNeeded() == false)
#expect(harness.importer.sweepIfNeeded() == false)
#expect(harness.accounts.accounts == accountsAfterFirst)
#expect(harness.accounts.pendingUploads == uploadsAfterFirst)
#expect(harness.accounts.storedContext == contextAfterFirst)
#expect(harness.credentials.credential(for: account) == "secret")
}
@Test("A sign-out and sign-in cycle imports only what is new")
func repeatedCyclesImportOnlyTheNewAccounts() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let first = harness.accounts.addAccount(instanceID: UUID(), displayName: "First")
harness.credentials.setCredential("first-secret", for: first)
defer { harness.cleanUp(first.id) }
try await harness.flip(to: "identity-A")
#expect(harness.accounts.accounts.map(\.id) == [first.id])
// Signed out again: the local namespace is empty, and whatever is added
// now lands there.
try await harness.flip(to: CredentialIdentityScope.localScope)
#expect(harness.accounts.accounts.isEmpty)
let second = harness.accounts.addAccount(instanceID: UUID(), displayName: "Second")
harness.credentials.setCredential("second-secret", for: second)
defer { harness.cleanUp(second.id) }
try await harness.flip(to: "identity-A")
#expect(harness.accounts.accounts.count == 2)
#expect(harness.accounts.account(id: first.id)?.displayName == "First")
#expect(harness.accounts.account(id: second.id)?.displayName == "Second")
#expect(harness.credentials.credential(for: first) == "first-secret")
#expect(harness.credentials.credential(for: second) == "second-secret")
#expect(harness.storedAccounts(in: localScope).isEmpty)
}
@Test("The signed-out migration markers stay behind, so nothing is synthesized twice")
func localMigrationMarkersAreRetained() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let instance = harness.addInstance(usesAccountLogin: true)
defer { harness.cleanUp(instance.id) }
harness.accounts.reconcileLegacyAccounts()
let synthesized = try #require(harness.accounts.accounts(forInstance: instance.id).first)
#expect(synthesized.id == instance.id)
try await harness.flip(to: "identity-A")
#expect(harness.accounts.account(id: instance.id) != nil)
#expect(harness.accounts.migratedInstanceIDs.contains(instance.id))
// Signing out again must not re-synthesize the account that just left:
// `usesAccountLogin` is still true on the instance forever, and only the
// retained marker stops the migration running a second time.
try await harness.flip(to: CredentialIdentityScope.localScope)
#expect(harness.accounts.accounts.isEmpty)
harness.accounts.reconcileLegacyAccounts()
#expect(harness.accounts.accounts.isEmpty)
#expect(harness.accounts.migratedInstanceIDs.contains(instance.id))
}
@Test("A deletion made while signed out is never published into the Apple account")
func localDeletionFactsStayLocal() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let doomed = harness.accounts.addAccount(instanceID: UUID())
harness.accounts.removeAccount(doomed.id)
let kept = harness.accounts.addAccount(instanceID: UUID())
defer { harness.cleanUp(doomed.id, kept.id) }
try await harness.flip(to: "identity-A")
#expect(harness.accounts.accounts.map(\.id) == [kept.id])
#expect(harness.accounts.deletedAccountIDs.isEmpty)
#expect(harness.accounts.pendingDeletions.isEmpty)
// ...and the local namespace keeps them, so signing out still works.
#expect(
harness.defaults.array(forKey: "accounts.local.deletedAccounts") as? [String]
== [doomed.id.uuidString]
)
}
// MARK: - Fences
@Test("A sweep is refused while the account namespace is still reloading")
func sweepIsFencedOnTheLoadedNamespace() {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let account = harness.accounts.addAccount(instanceID: UUID())
harness.credentials.setCredential("secret", for: account)
defer { harness.cleanUp(account.id) }
// The scope moves before the observer that reloads the manager runs.
harness.scope.setFixedScope("identity-A", announcing: false)
#expect(harness.importer.sweepIfNeeded() == false)
#expect(harness.storedAccounts(in: localScope).map(\.id) == [account.id])
#expect(harness.defaults.data(forKey: "accounts.identity-A.accounts") == nil)
#expect(harness.rawCredential(scope: localScope, accountID: account.id) == "secret")
}
@Test("An unresolved scope is never a destination, and the identity sweeps once")
func unresolvedScopeIsNotADestination() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let account = harness.accounts.addAccount(instanceID: UUID(), displayName: "Main")
harness.credentials.setCredential("secret", for: account)
defer { harness.cleanUp(account.id) }
// iCloud is signed in but the user record has not been fetched yet.
try await harness.flip(to: nil)
#expect(harness.sweeps == 0)
#expect(harness.storedAccounts(in: localScope).map(\.id) == [account.id])
#expect(harness.defaults.data(forKey: "accounts.unresolved.accounts") == nil)
try await harness.flip(to: "identity-A")
#expect(harness.sweeps == 1)
#expect(harness.accounts.account(id: account.id) != nil)
// Any later activation of the same identity finds nothing left to do.
harness.accounts.reloadForCurrentIdentity()
#expect(harness.sweeps == 1)
#expect(harness.accounts.account(id: account.id) != nil)
}
@Test("Whichever identity activates claims what was made while signed out")
func identitySwitchClaimsTheLocalNamespace() async throws {
let harness = Harness(startingScope: "identity-A")
let held = harness.accounts.addAccount(instanceID: UUID(), displayName: "A's own")
defer { harness.cleanUp(held.id) }
// Made while the device was signed out, before B signed in.
let madeSignedOut = UUID()
defer { harness.cleanUp(madeSignedOut) }
harness.seed(CredentialIdentityScope.localScope) { manager in
manager.applySyncedAccount(
Account(id: madeSignedOut, instanceID: UUID(), displayName: "Made signed out")
)
}
harness.seedCredential("local-secret", accountID: madeSignedOut, scope: localScope)
try await harness.flip(to: "identity-B")
#expect(harness.accounts.account(id: madeSignedOut)?.displayName == "Made signed out")
#expect(harness.accounts.pendingUploads.contains(madeSignedOut))
#expect(harness.rawCredential(scope: "identity-B", accountID: madeSignedOut) == "local-secret")
#expect(harness.storedAccounts(in: localScope).isEmpty)
// A's own namespace is untouched by B claiming the local one.
#expect(harness.storedAccounts(in: "identity-A").map(\.id) == [held.id])
#expect(harness.accounts.account(id: held.id) == nil)
}
}
// MARK: - Test Support
/// A full local-namespace setup: storage, a Keychain service, an identity scope
/// that can be moved, and the importer wired the way the app wires it.
@MainActor
private final class Harness {
let defaults: UserDefaults
let scope: CredentialIdentityScope
let instances: InstancesManager
let accounts: AccountsManager
let credentials: TestCredentialsManager
let importer: LocalNamespaceImporter
/// Sweeps that actually took something over.
private(set) var sweeps = 0
private let store: KeychainCredentialStore
/// - Parameter credentialStore: replaces the real, Keychain-backed store the
/// importer is given, for the failures a live Keychain cannot be made to
/// produce on demand.
init(startingScope: String?, credentialStore substitute: (any AccountCredentialsManager)? = nil) {
let defaults = UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!
// Unique per run so a crashed run's leftovers can never be read back.
let service = "com.yattee.tests.\(UUID().uuidString)"
self.defaults = defaults
self.store = KeychainCredentialStore(service: service)
self.instances = makeTestInstancesManager()
let scope = CredentialIdentityScope(fixedScope: startingScope)
self.scope = scope
let accounts = AccountsManager(
identityScope: scope,
instancesManager: instances,
defaults: defaults
)
self.accounts = accounts
let credentials = TestCredentialsManager(
service: service,
identityScope: scope,
markers: CredentialAdoptionMarkerStore(defaults: defaults)
)
self.credentials = credentials
self.importer = LocalNamespaceImporter(
accountsManager: accounts,
identityScope: scope,
credentialStores: { _ in [substitute ?? credentials] }
)
accounts.onNamespaceActivated = { [weak self] in
guard let self, self.importer.sweepIfNeeded() else { return }
self.sweeps += 1
}
}
// MARK: Driving
/// Moves the identity scope the way a sign-in does, and lets the main-queue
/// observers that reload and sweep run.
func flip(to newScope: String?) async throws {
scope.setFixedScope(newScope)
try await Task.sleep(for: .milliseconds(50))
}
@discardableResult
func addInstance(usesAccountLogin: Bool = false) -> Instance {
let instance = Instance(
type: .invidious,
url: URL(string: "https://import-\(UUID().uuidString).example.com")!,
usesAccountLogin: usesAccountLogin
)
instances.add(instance)
return instance
}
/// Writes into a namespace other than the one under test, the way another
/// device's sync would have filled it.
func seed(_ namespace: String, _ body: (AccountsManager) -> Void) {
body(
AccountsManager(
identityScope: CredentialIdentityScope(fixedScope: namespace),
instancesManager: instances,
defaults: defaults
)
)
}
func seedCredential(_ value: String, accountID: UUID, scope: String) {
store.set(
value,
forKey: KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID),
synchronizable: false
)
}
func seedUsername(_ value: String, accountID: UUID, scope: String) {
store.set(
value,
forKey: KeychainCredentialStore.usernameKey(scope: scope, accountID: accountID),
synchronizable: false
)
}
// MARK: Reading storage directly
func storedAccounts(in namespace: String) -> [Account] {
guard let data = defaults.data(forKey: "accounts.\(namespace).accounts"),
let decoded = try? JSONDecoder().decode([Account].self, from: data)
else { return [] }
return decoded
}
func rawCredential(scope: String, accountID: UUID) -> String? {
store.value(forKey: KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID))
}
func rawUsername(scope: String, accountID: UUID) -> String? {
store.value(forKey: KeychainCredentialStore.usernameKey(scope: scope, accountID: accountID))
}
/// The Keychain outlives the test process, so every key a test could have
/// written is removed by hand.
func cleanUp(_ accountIDs: UUID...) {
for accountID in accountIDs {
for scope in [CredentialIdentityScope.localScope, "identity-A", "identity-B", "unresolved"] {
store.delete(key: KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID))
store.delete(key: KeychainCredentialStore.usernameKey(scope: scope, accountID: accountID))
}
store.delete(key: KeychainCredentialStore.legacyKey(instanceID: accountID))
}
}
}
/// An account-keyed credential store over a Keychain service of its own.
///
/// The shipping managers hard-code their service names, which under a test host
/// are the ones the installed app uses. This wraps the same engine they wrap, so
/// the protocol the importer talks to is exercised for real.
@MainActor
@Observable
private final class TestCredentialsManager: AccountCredentialsManager {
private let credentials: AccountKeychainCredentials
init(service: String, identityScope: CredentialIdentityScope, markers: CredentialAdoptionMarkerStore) {
credentials = AccountKeychainCredentials(
service: service,
credentialNoun: "credential",
identityScope: identityScope,
markers: markers
)
}
var loggedInAccountIDs: Set<UUID> { credentials.loggedInAccountIDs }
var isScopeResolved: Bool { credentials.isScopeResolved }
func setCredential(_ credential: String, for account: Account) {
credentials.setCredential(credential, for: account)
}
func credential(for account: Account) -> String? { credentials.credential(for: account) }
func deleteCredential(for account: Account) { credentials.deleteCredential(for: account) }
func deleteCredential(forAccountID accountID: UUID) {
credentials.deleteCredential(forAccountID: accountID)
}
func resyncCredentialsWithCurrentSetting() { credentials.resyncCredentialsWithCurrentSetting() }
func setUsername(_ username: String, for account: Account) {
credentials.setUsername(username, for: account)
}
func username(for account: Account) -> String? { credentials.username(for: account) }
func isLoggedIn(for account: Account) -> Bool { credentials.isLoggedIn(for: account) }
func refreshLoginStatus(for account: Account) { credentials.refreshLoginStatus(for: account) }
@discardableResult
func migrateCredential(forAccountID accountID: UUID, fromScope sourceScope: String) -> Bool {
credentials.migrateCredential(forAccountID: accountID, fromScope: sourceScope)
}
@discardableResult
func deleteCredential(forAccountID accountID: UUID, inScope scope: String) -> Bool {
credentials.deleteCredential(forAccountID: accountID, inScope: scope)
}
func hasAdoptableLegacyCredential(for account: Account) -> Bool {
credentials.hasAdoptableLegacyCredential(for: account)
}
@discardableResult
func adoptLegacyCredential(for account: Account) -> Bool {
credentials.adoptLegacyCredential(for: account)
}
func deleteLegacyCredentialIfOwned(for account: Account) {
credentials.deleteLegacyCredentialIfOwned(for: account)
}
}
/// A credential store that can be told to refuse the two operations the sweep's
/// per-item atomicity rests on.
///
/// The real Keychain cannot be made to fail a delete on demand — `SecItemDelete`
/// against a key that exists and is accessible succeeds — so the failure branch
/// is exercised through the protocol the importer actually talks to. What the
/// engine does when it *can* complete the move is
/// `AccountKeychainCredentials`' own tests' subject, driven against a live
/// Keychain by the rest of this suite.
@MainActor
@Observable
private final class FlakyCredentialsManager: AccountCredentialsManager {
var refusesEverything = true
private(set) var migrateCalls = 0
private(set) var deleteCalls = 0
var loggedInAccountIDs: Set<UUID> { [] }
var isScopeResolved: Bool { true }
func setCredential(_: String, for _: Account) {}
func credential(for _: Account) -> String? { nil }
func deleteCredential(for _: Account) {}
func deleteCredential(forAccountID _: UUID) {}
func resyncCredentialsWithCurrentSetting() {}
func setUsername(_: String, for _: Account) {}
func username(for _: Account) -> String? { nil }
func isLoggedIn(for _: Account) -> Bool { false }
func refreshLoginStatus(for _: Account) {}
@discardableResult
func migrateCredential(forAccountID _: UUID, fromScope _: String) -> Bool {
migrateCalls += 1
return !refusesEverything
}
@discardableResult
func deleteCredential(forAccountID _: UUID, inScope _: String) -> Bool {
deleteCalls += 1
return !refusesEverything
}
func hasAdoptableLegacyCredential(for _: Account) -> Bool { false }
@discardableResult
func adoptLegacyCredential(for _: Account) -> Bool { false }
func deleteLegacyCredentialIfOwned(for _: Account) {}
}