Files
yattee/YatteeTests/LocalNamespaceImportTests.swift
Arkadiusz Fal 4770bbe3b9 Remove dead code found by Periphery on all platforms
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
2026-10-08 23:57:56 +02:00

946 lines
42 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 stays where its secret is: entirely local, with the
// identity owning no part of it. Handing it over first would announce an
// account whose credential is still in another namespace — and leave it
// owned by two identities if the user signed into a second one before
// the retry.
#expect(flaky.migrateCalls == 1)
#expect(harness.accounts.account(id: account.id) == nil)
#expect(harness.accounts.pendingUploads.isEmpty)
#expect(harness.storedAccounts(in: "identity-A").isEmpty)
#expect(harness.storedAccounts(in: localScope).map(\.id) == [account.id])
// The retry converges: with the secret settled, the record follows it.
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 settlement that fails leaves nothing for a second identity to collide with")
func aFailedSettlementNeverLeavesTheAccountInTwoIdentities() 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")
// A holds nothing of it, so nothing of it is A's to keep.
#expect(harness.accounts.account(id: account.id) == nil)
#expect(harness.storedAccounts(in: "identity-A").isEmpty)
#expect(harness.storedAccounts(in: localScope).map(\.id) == [account.id])
// B signs in before A ever gets its retry. There is exactly one owner
// afterwards — the alternative, a record adopted beside a credential
// that would not follow, is an account permanently in two Apple
// accounts with nothing able to take it back out of the first.
flaky.refusesEverything = false
try await harness.flip(to: "identity-B")
#expect(harness.accounts.account(id: account.id)?.displayName == "Main")
#expect(harness.storedAccounts(in: "identity-B").map(\.id) == [account.id])
#expect(harness.storedAccounts(in: "identity-A").isEmpty)
#expect(harness.storedAccounts(in: localScope).isEmpty)
}
// MARK: - Ordering
@Test("An imported record is never announced before the credential that goes with it")
func nothingIsAnnouncedBeforeItsCredentialSettles() 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) }
// What this guards against is an observer of `.accountsDidChange` acting
// on a record the moment it appears — the status refresh, and the silent
// legacy adoption it can reach, which would fill the empty identity key
// with the pre-revamp unscoped secret and leave the real login to be
// deleted as redundant. Announcements are answered here on exactly those
// terms: synchronously, from inside the sweep, asking the only question
// such an observer would act on.
let witness = AnnouncementWitness()
let observer = NotificationCenter.default.addObserver(
forName: .accountsDidChange,
object: nil,
queue: nil
) { _ in
MainActor.assumeIsolated {
guard harness.accounts.account(id: account.id) != nil else { return }
witness.samples.append(harness.rawCredential(scope: "identity-A", accountID: account.id))
}
}
defer { NotificationCenter.default.removeObserver(observer) }
try await harness.flip(to: "identity-A")
// The record was announced, and the secret was already there every time.
#expect(!witness.samples.isEmpty)
#expect(witness.samples.allSatisfy { $0 == "secret" })
#expect(harness.rawCredential(scope: localScope, accountID: account.id) == nil)
#expect(harness.storedAccounts(in: localScope).isEmpty)
}
// MARK: - Provenance
@Test("A move interrupted before its provenance was filed repairs it on the retry")
func adoptionProvenanceIsRepairedOnTheRetry() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let instance = harness.addInstance(usesAccountLogin: true)
harness.accounts.reconcileLegacyAccounts()
let account = try #require(harness.accounts.accounts(forInstance: instance.id).first)
defer { harness.cleanUp(account.id, instance.id) }
// What a process death between the verified copy and the marker leaves:
// the adopted secret is already under the identity, the namespace being
// left still holds its copy, and the only record of where that secret
// came from is filed under that namespace.
harness.seedCredential("legacy-secret", accountID: account.id, scope: localScope)
harness.seedCredential("legacy-secret", accountID: account.id, scope: "identity-A")
harness.markers.record(
service: harness.service,
instanceID: account.id,
accountID: account.id,
scope: localScope,
secret: "legacy-secret"
)
try await harness.flip(to: "identity-A")
// The retry recognises its own interrupted work by the fingerprint and
// re-files the provenance where the secret now is. Without it the next
// resync would read an unmarked credential as one the user proved they
// hold, and upload a secret of unproven origin into this identity's
// iCloud Keychain.
#expect(
harness.markers.hasAdopted(
service: harness.service,
instanceID: account.id,
scope: "identity-A",
matching: "legacy-secret"
)
)
#expect(!harness.markers.hasAdopted(service: harness.service, instanceID: account.id, scope: localScope))
#expect(harness.rawCredential(scope: "identity-A", accountID: account.id) == "legacy-secret")
#expect(harness.rawCredential(scope: localScope, accountID: account.id) == nil)
#expect(harness.storedAccounts(in: localScope).isEmpty)
}
@Test("A real sign-in the adopted copy was redundant beside is not marked as adopted")
func aGenuineCredentialIsNotClaimedByTheMarker() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let instance = harness.addInstance(usesAccountLogin: true)
harness.accounts.reconcileLegacyAccounts()
let account = try #require(harness.accounts.accounts(forInstance: instance.id).first)
defer { harness.cleanUp(account.id, instance.id) }
// Same shape, different secret under the identity: this one was signed
// in for, and the adopted copy is genuinely redundant beside it.
harness.seedCredential("legacy-secret", accountID: account.id, scope: localScope)
harness.seedCredential("signed-in-secret", accountID: account.id, scope: "identity-A")
harness.markers.record(
service: harness.service,
instanceID: account.id,
accountID: account.id,
scope: localScope,
secret: "legacy-secret"
)
try await harness.flip(to: "identity-A")
// Nothing about the adoption applies to a credential the user proved
// they hold — claiming it would keep a real login off iCloud Keychain
// forever.
#expect(!harness.markers.hasAdopted(service: harness.service, instanceID: account.id, scope: "identity-A"))
// ...and the marker for the copy that was deleted goes with it.
#expect(!harness.markers.hasAdopted(service: harness.service, instanceID: account.id, scope: localScope))
#expect(harness.rawCredential(scope: "identity-A", accountID: account.id) == "signed-in-secret")
#expect(harness.rawCredential(scope: localScope, accountID: account.id) == nil)
}
@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 choice made while signed out beats a context the identity only derived")
func aCarriedChoiceBeatsAnAutoDerivedContext() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let account = harness.accounts.addAccount(instanceID: UUID(), displayName: "Main")
defer { harness.cleanUp(account.id) }
harness.accounts.setContext(.account(account.id))
// The pre-revamp selection is not namespaced by identity, so it is still
// readable when the identity's namespace loads — and
// `reloadForCurrentIdentity` turns it into a context *before* the sweep
// runs. Refusing to carry anything over on the strength of that would
// lose the user's own choice to a value nobody made, with the local keys
// cleared behind it.
harness.defaults.set(
try JSONEncoder().encode(SubscriptionAccount(type: .local, instanceID: nil)),
forKey: "subscriptionAccount"
)
try await harness.flip(to: "identity-A")
#expect(harness.accounts.currentContext == .account(account.id))
// Still epoch-stamped: a choice made while signed out is a guess at what
// the user wants under *this* Apple account, so a real one from any of
// their devices outranks it whichever arrives first.
#expect(harness.accounts.storedContext.isMigrated)
#expect(harness.accounts.pendingContextUpload)
#expect(harness.defaults.data(forKey: "accounts.local.context") == nil)
#expect(!harness.defaults.bool(forKey: "accounts.local.hasContext"))
}
@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)
// An explicit choice under this identity is not epoch-stamped, and is
// exactly what the carried one must not displace.
#expect(!harness.accounts.storedContext.isMigrated)
// 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 marker for an account deleted while signed out is not copied over")
func markersWithoutAnAccountAreNotAdopted() async throws {
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
let keptInstanceID = UUID()
let abandonedInstanceID = UUID()
let kept = harness.accounts.addAccount(instanceID: keptInstanceID)
// Created and then deleted while signed out. Creating it marked its
// instance migrated, and the deletion deliberately leaves that marker
// standing — locally, where it belongs.
let doomed = harness.accounts.addAccount(instanceID: abandonedInstanceID)
harness.accounts.removeAccount(doomed.id)
defer { harness.cleanUp(kept.id, doomed.id) }
#expect(harness.localMigratedInstanceIDs() == [keptInstanceID, abandonedInstanceID])
try await harness.flip(to: "identity-A")
// A marker suppresses synthesis wherever it lands, so one whose account
// no longer exists would say "this instance's legacy login is already an
// account" under an identity where it is not — a deletion made while
// signed out leaking into an Apple account, which decision 3 refuses.
#expect(harness.accounts.migratedInstanceIDs == [keptInstanceID])
#expect(harness.accounts.pendingMarkerUploads == [keptInstanceID])
// The local set itself is untouched either way (decision 4).
#expect(harness.localMigratedInstanceIDs() == [keptInstanceID, abandonedInstanceID])
}
@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
/// The provenance records that say a credential was adopted rather than
/// signed in for. Reachable so a test can leave one where an interrupted
/// move would have.
let markers: CredentialAdoptionMarkerStore
/// The Keychain service the markers are filed against.
let service: String
/// 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.service = service
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 markers = CredentialAdoptionMarkerStore(defaults: defaults)
self.markers = markers
let credentials = TestCredentialsManager(
service: service,
identityScope: scope,
markers: markers
)
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 localMigratedInstanceIDs() -> Set<UUID> {
let stored = defaults.array(forKey: "accounts.local.migrated") as? [String] ?? []
return Set(stored.compactMap(UUID.init(uuidString:)))
}
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))
}
}
}
/// What the identity could see of a credential each time it announced a record.
///
/// A reference type so the notification block can write to it without capturing
/// anything of the test's own state.
@MainActor
private final class AnnouncementWitness {
var samples: [String?] = []
}
/// 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 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 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 isScopeResolved: Bool { true }
func setCredential(_: String, for _: Account) {}
func credential(for _: Account) -> String? { nil }
func deleteCredential(for _: Account) {}
func deleteCredential(forAccountID _: UUID) {}
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) {}
}