Files
yattee/YatteeTests/AccountCredentialsTests.swift
Arkadiusz Fal abd0f8ddbb Let only the adopting account retire its marker
Found on the iPad: after signing a second login into an instance, its
migrated account could no longer retire the unscoped legacy item when it
was deleted, and the instance was left still claiming a pre-revamp login.

Storing a credential retires the adoption marker, but it matched on
service, instance and identity only. An instance holds several logins, so
a sibling sign-in silently withdrew the migrated account's claim on a
secret it had nothing to do with. After that the legacy item outlived the
account that adopted it, and could be adopted a second time.

Erring that way costs nothing — an item that is not deleted is an item
nobody is logged out of — but the marker already carries the account it
was written for, and deletion authority already checks it. Retiring it
now matches on the same thing.
2026-09-01 23:52:59 +02:00

1010 lines
41 KiB
Swift

//
// AccountCredentialsTests.swift
// YatteeTests
//
// Identity-scoped, account-keyed credential storage and legacy adoption.
//
import Testing
import Foundation
@testable import Yattee
@Suite("Account credential scoping")
@MainActor
struct AccountCredentialScopingTests {
/// A service name unique per test run so parallel runs and leftovers from a
/// crashed run can never collide in the real Keychain.
private func makeService() -> String {
"com.yattee.tests.\(UUID().uuidString)"
}
private func makeCredentials(
service: String,
scope: String?,
markers: CredentialAdoptionMarkerStore
) -> AccountKeychainCredentials {
AccountKeychainCredentials(
service: service,
credentialNoun: "credential",
identityScope: CredentialIdentityScope(fixedScope: scope),
markers: markers
)
}
private func makeMarkers() -> CredentialAdoptionMarkerStore {
CredentialAdoptionMarkerStore(
defaults: UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!
)
}
@Test("Credential key carries the identity scope")
func credentialKeyIsScoped() {
let accountID = UUID()
let key = KeychainCredentialStore.credentialKey(scope: "identity-A", accountID: accountID)
#expect(key == "identity-A|\(accountID.uuidString)")
#expect(
KeychainCredentialStore.usernameKey(scope: "identity-A", accountID: accountID)
== "identity-A|\(accountID.uuidString)#username"
)
// The legacy key must stay a bare UUID, or existing items stop resolving.
#expect(KeychainCredentialStore.legacyKey(instanceID: accountID) == accountID.uuidString)
}
@Test("A credential stored under one Apple account is invisible to another")
func credentialsAreIsolatedAcrossIdentities() {
let service = makeService()
let markers = makeMarkers()
let account = Account(instanceID: UUID())
let identityA = makeCredentials(service: service, scope: "identity-A", markers: markers)
identityA.setCredential("secret-a", for: account)
#expect(identityA.credential(for: account) == "secret-a")
// Same device, same deterministic account UUID, different Apple account.
let identityB = makeCredentials(service: service, scope: "identity-B", markers: markers)
#expect(identityB.credential(for: account) == nil)
#expect(identityB.isLoggedIn(for: account) == false)
// A's credential is untouched when A comes back.
let identityAAgain = makeCredentials(service: service, scope: "identity-A", markers: markers)
#expect(identityAAgain.credential(for: account) == "secret-a")
identityAAgain.deleteCredential(for: account)
}
@Test("Usernames are scoped exactly like credentials")
func usernamesAreScoped() {
let service = makeService()
let markers = makeMarkers()
let account = Account(instanceID: UUID())
let identityA = makeCredentials(service: service, scope: "identity-A", markers: markers)
identityA.setUsername("someone@example.com", for: account)
#expect(identityA.username(for: account) == "someone@example.com")
let identityB = makeCredentials(service: service, scope: "identity-B", markers: markers)
#expect(identityB.username(for: account) == nil)
identityA.deleteCredential(for: account)
#expect(identityA.username(for: account) == nil)
}
@Test("An unresolved scope refuses writes and reports nothing signed in")
func unresolvedScopeRefusesAccess() {
let service = makeService()
let account = Account(instanceID: UUID())
let credentials = makeCredentials(service: service, scope: nil, markers: makeMarkers())
#expect(credentials.isScopeResolved == false)
credentials.setCredential("should-not-persist", for: account)
// Nothing was written under a guessed scope, so nothing can be read back
// once the real scope arrives.
let resolved = makeCredentials(service: service, scope: "identity-A", markers: makeMarkers())
#expect(resolved.credential(for: account) == nil)
}
@Test("Switching identity drops the cached login set")
func identitySwitchClearsLoginCache() {
let service = makeService()
let markers = makeMarkers()
let account = Account(instanceID: UUID())
let scope = CredentialIdentityScope(fixedScope: "identity-A")
let credentials = AccountKeychainCredentials(
service: service,
credentialNoun: "credential",
identityScope: scope,
markers: markers
)
credentials.setCredential("secret-a", for: account)
#expect(credentials.loggedInAccountIDs.contains(account.id))
scope.adoptCurrentIdentity() // real device identity — no longer "identity-A"
#expect(credentials.isLoggedIn(for: account) == false)
#expect(!credentials.loggedInAccountIDs.contains(account.id))
let cleanup = makeCredentials(service: service, scope: "identity-A", markers: markers)
cleanup.deleteCredential(for: account)
}
}
@Suite("Legacy credential adoption")
@MainActor
struct LegacyCredentialAdoptionTests {
private func makeService() -> String {
"com.yattee.tests.\(UUID().uuidString)"
}
private func makeMarkers() -> CredentialAdoptionMarkerStore {
CredentialAdoptionMarkerStore(
defaults: UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!
)
}
/// Writes the pre-revamp item: unscoped, keyed by bare instance UUID.
private func seedLegacyItem(service: String, instanceID: UUID, value: String) {
let store = KeychainCredentialStore(service: service)
store.set(value, forKey: KeychainCredentialStore.legacyKey(instanceID: instanceID), synchronizable: false)
}
private func legacyValue(service: String, instanceID: UUID) -> String? {
KeychainCredentialStore(service: service)
.value(forKey: KeychainCredentialStore.legacyKey(instanceID: instanceID))
}
private func makeCredentials(
service: String,
scope: String,
markers: CredentialAdoptionMarkerStore,
settingsManager: SettingsManager? = nil
) -> AccountKeychainCredentials {
let credentials = AccountKeychainCredentials(
service: service,
credentialNoun: "credential",
identityScope: CredentialIdentityScope(fixedScope: scope),
markers: markers
)
credentials.settingsManager = settingsManager
return credentials
}
@Test("Reading a credential never falls back to the unscoped legacy item")
func readingNeverFallsBackToLegacyItem() {
let service = makeService()
let instanceID = UUID()
let account = Account.migrated(from: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let credentials = makeCredentials(service: service, scope: "identity-B", markers: makeMarkers())
// The whole point of scoping: the legacy item is reachable only through
// the adoption path, which copies it deliberately and records what it
// took. An implicit read-through would leave nothing to bound deletion
// with later.
#expect(credentials.credential(for: account) == nil)
#expect(credentials.isLoggedIn(for: account) == false)
#expect(credentials.hasAdoptableLegacyCredential(for: account) == true)
KeychainCredentialStore(service: service)
.delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID))
}
@Test("An adopted credential is kept on this device even when sync is on")
func adoptedCredentialIsNeverSynchronizable() {
let service = makeService()
let instanceID = UUID()
let account = Account.migrated(from: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let settings = SettingsManager()
let previousSync = settings.iCloudSyncEnabled
let previousInstances = settings.syncInstances
settings.iCloudSyncEnabled = true
settings.syncInstances = true
defer {
settings.iCloudSyncEnabled = previousSync
settings.syncInstances = previousInstances
}
let credentials = makeCredentials(
service: service,
scope: "identity-A",
markers: makeMarkers(),
settingsManager: settings
)
#expect(credentials.adoptLegacyCredential(for: account) == true)
// A retained legacy item cannot be shown to belong to the identity
// signed in now. Adopting it locally leaves exposure where the previous
// release already had it; writing it synchronizably would upload
// somebody else's secret into this identity's iCloud Keychain.
let store = KeychainCredentialStore(service: service)
let key = KeychainCredentialStore.credentialKey(scope: "identity-A", accountID: account.id)
#expect(store.isSynchronizable(key: key) == false)
credentials.deleteCredential(for: account)
store.delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID))
}
@Test("Turning iCloud sync on does not upload an adopted credential")
func resyncLeavesAdoptedCredentialsLocal() {
let service = makeService()
let instanceID = UUID()
let adopted = Account.migrated(from: instanceID)
let typedIn = Account(instanceID: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let settings = SettingsManager()
let previousSync = settings.iCloudSyncEnabled
let previousInstances = settings.syncInstances
settings.iCloudSyncEnabled = false
settings.syncInstances = false
defer {
settings.iCloudSyncEnabled = previousSync
settings.syncInstances = previousInstances
}
let credentials = makeCredentials(
service: service,
scope: "identity-A",
markers: makeMarkers(),
settingsManager: settings
)
#expect(credentials.adoptLegacyCredential(for: adopted) == true)
credentials.setCredential("typed-in-secret", for: typedIn)
settings.iCloudSyncEnabled = true
settings.syncInstances = true
credentials.resyncCredentialsWithCurrentSetting()
let store = KeychainCredentialStore(service: service)
// The login the user typed is theirs, and they just asked for it to
// sync.
#expect(
store.isSynchronizable(
key: KeychainCredentialStore.credentialKey(scope: "identity-A", accountID: typedIn.id)
) == true
)
// The adopted one is left alone — its provenance is unproven, and the
// legacy item it came from already syncs for whoever really owns it.
#expect(
store.isSynchronizable(
key: KeychainCredentialStore.credentialKey(scope: "identity-A", accountID: adopted.id)
) == false
)
credentials.deleteCredential(for: adopted)
credentials.deleteCredential(for: typedIn)
store.delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID))
}
@Test("An adopted credential stays local even if its marker was never written")
func resyncRecognizesAnAdoptedCopyWithoutItsMarker() {
let service = makeService()
let instanceID = UUID()
let account = Account.migrated(from: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let settings = SettingsManager()
let previousSync = settings.iCloudSyncEnabled
let previousInstances = settings.syncInstances
settings.iCloudSyncEnabled = false
settings.syncInstances = false
defer {
settings.iCloudSyncEnabled = previousSync
settings.syncInstances = previousInstances
}
let store = KeychainCredentialStore(service: service)
let key = KeychainCredentialStore.credentialKey(scope: "identity-A", accountID: account.id)
// Exactly the state left behind when the app dies between the adoption
// copy and the marker that describes it: the scoped item exists, no
// marker claims it, and adoption will not retry because the key is
// occupied.
store.set("legacy-secret", forKey: key, synchronizable: false)
let credentials = makeCredentials(
service: service,
scope: "identity-A",
markers: makeMarkers(),
settingsManager: settings
)
#expect(credentials.isLoggedIn(for: account) == true)
settings.iCloudSyncEnabled = true
settings.syncInstances = true
credentials.resyncCredentialsWithCurrentSetting()
#expect(store.isSynchronizable(key: key) == false)
credentials.deleteCredential(for: account)
store.delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID))
}
@Test("Signing in again lets the new credential sync")
func signingInAgainClearsTheAdoptionMarker() {
let service = makeService()
let instanceID = UUID()
let account = Account.migrated(from: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let settings = SettingsManager()
let previousSync = settings.iCloudSyncEnabled
let previousInstances = settings.syncInstances
settings.iCloudSyncEnabled = false
settings.syncInstances = false
defer {
settings.iCloudSyncEnabled = previousSync
settings.syncInstances = previousInstances
}
let markers = makeMarkers()
let credentials = makeCredentials(
service: service,
scope: "identity-A",
markers: markers,
settingsManager: settings
)
#expect(credentials.adoptLegacyCredential(for: account) == true)
// The user signs in again while sync is off, so the replacement is
// stored locally like any other credential.
credentials.setCredential("freshly-typed-secret", for: account)
#expect(markers.hasAdopted(service: service, instanceID: instanceID, scope: "identity-A") == false)
// Turning sync on must now carry it to the other devices — they are
// showing an account whose credential never arrives.
settings.iCloudSyncEnabled = true
settings.syncInstances = true
credentials.resyncCredentialsWithCurrentSetting()
let store = KeychainCredentialStore(service: service)
#expect(
store.isSynchronizable(
key: KeychainCredentialStore.credentialKey(scope: "identity-A", accountID: account.id)
) == true
)
credentials.deleteCredential(for: account)
store.delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID))
}
@Test("A sibling sign-in does not retire the adopter's marker")
func siblingSignInLeavesTheAdoptionMarkerAlone() {
let service = makeService()
let markers = makeMarkers()
let instanceID = UUID()
let adopter = Account.migrated(from: instanceID)
// A second login the user adds on the same instance.
let sibling = Account(instanceID: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let credentials = makeCredentials(service: service, scope: "identity-A", markers: markers)
#expect(credentials.adoptLegacyCredential(for: adopter) == true)
// Signing the sibling in says nothing about the account that adopted
// this instance's pre-revamp secret.
credentials.setCredential("sibling-secret", for: sibling)
#expect(markers.hasAdopted(service: service, instanceID: instanceID, scope: "identity-A") == true)
// So the adopter can still retire the legacy item when it is deleted —
// matching on the instance alone silently withdrew that claim.
credentials.deleteLegacyCredentialIfOwned(for: adopter)
#expect(legacyValue(service: service, instanceID: instanceID) == nil)
credentials.deleteCredential(for: adopter)
credentials.deleteCredential(for: sibling)
}
@Test("A login the user added themselves never inherits the legacy item")
func onlyTheMigratedAccountAdopts() {
let service = makeService()
let instanceID = UUID()
// Not `Account.migrated`: a second login on the same instance, created
// by signing in. The legacy item is keyed by instance, so without the
// migrated-account rule this one would match it.
let sibling = Account(instanceID: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let credentials = makeCredentials(service: service, scope: "identity-A", markers: makeMarkers())
#expect(credentials.hasAdoptableLegacyCredential(for: sibling) == false)
#expect(credentials.adoptLegacyCredential(for: sibling) == false)
#expect(credentials.credential(for: sibling) == nil)
KeychainCredentialStore(service: service)
.delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID))
}
@Test("Adoption copies the legacy item and retains it")
func adoptionCopiesAndRetains() {
let service = makeService()
let instanceID = UUID()
let account = Account.migrated(from: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let credentials = makeCredentials(service: service, scope: "identity-A", markers: makeMarkers())
#expect(credentials.adoptLegacyCredential(for: account) == true)
#expect(credentials.credential(for: account) == "legacy-secret")
// Retained: deleting it would propagate through iCloud Keychain and log
// out devices still running the previous release.
#expect(legacyValue(service: service, instanceID: instanceID) == "legacy-secret")
// Adopted once, never offered again for this identity.
#expect(credentials.hasAdoptableLegacyCredential(for: account) == false)
credentials.deleteCredential(for: account)
KeychainCredentialStore(service: service)
.delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID))
}
@Test("Adoption by one identity does not suppress the offer for another")
func adoptionIsPerIdentity() {
let service = makeService()
let markers = makeMarkers()
let instanceID = UUID()
let account = Account.migrated(from: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let identityA = makeCredentials(service: service, scope: "identity-A", markers: markers)
#expect(identityA.adoptLegacyCredential(for: account) == true)
let identityB = makeCredentials(service: service, scope: "identity-B", markers: markers)
#expect(identityB.credential(for: account) == nil)
#expect(identityB.hasAdoptableLegacyCredential(for: account) == true)
identityA.deleteCredential(for: account)
KeychainCredentialStore(service: service)
.delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID))
}
@Test("Deleting an account removes the legacy item only for the adopting identity")
func legacyDeletionRequiresIdentityMatch() {
let service = makeService()
let markers = makeMarkers()
let instanceID = UUID()
let account = Account.migrated(from: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let identityA = makeCredentials(service: service, scope: "identity-A", markers: markers)
#expect(identityA.adoptLegacyCredential(for: account) == true)
// B never adopted anything, so B may not delete the shared legacy item.
let identityB = makeCredentials(service: service, scope: "identity-B", markers: markers)
identityB.deleteLegacyCredentialIfOwned(for: account)
#expect(legacyValue(service: service, instanceID: instanceID) == "legacy-secret")
// A adopted it and it still holds the adopted secret, so A may.
identityA.deleteLegacyCredentialIfOwned(for: account)
#expect(legacyValue(service: service, instanceID: instanceID) == nil)
identityA.deleteCredential(for: account)
}
@Test("Deleting a sibling account leaves the adopter's legacy item alone")
func legacyDeletionRequiresAccountMatch() {
let service = makeService()
let markers = makeMarkers()
let instanceID = UUID()
let adopter = Account.migrated(from: instanceID)
// A second login on the same instance — a fresh UUID, not the migrated one.
let sibling = Account(instanceID: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let credentials = makeCredentials(service: service, scope: "identity-A", markers: markers)
#expect(credentials.adoptLegacyCredential(for: adopter) == true)
// Removing the sibling must not retire the secret the migrated account
// adopted — old-release devices are still using it.
credentials.deleteLegacyCredentialIfOwned(for: sibling)
#expect(legacyValue(service: service, instanceID: instanceID) == "legacy-secret")
credentials.deleteLegacyCredentialIfOwned(for: adopter)
#expect(legacyValue(service: service, instanceID: instanceID) == nil)
credentials.deleteCredential(for: adopter)
}
@Test("Adoption does not overwrite a scoped credential that arrived first")
func adoptionDoesNotClobberArrivedCredential() {
let service = makeService()
let instanceID = UUID()
let account = Account.migrated(from: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let credentials = makeCredentials(service: service, scope: "identity-A", markers: makeMarkers())
// iCloud Keychain delivers the real credential between the offer being
// rendered and the user tapping it.
credentials.setCredential("synced-secret", for: account)
#expect(credentials.hasAdoptableLegacyCredential(for: account) == false)
#expect(credentials.adoptLegacyCredential(for: account) == false)
#expect(credentials.credential(for: account) == "synced-secret")
credentials.deleteCredential(for: account)
KeychainCredentialStore(service: service)
.delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID))
}
@Test("A legacy item replaced by another login survives the adopter's deletion")
func legacyDeletionRequiresFingerprintMatch() {
let service = makeService()
let markers = makeMarkers()
let instanceID = UUID()
let account = Account.migrated(from: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let identityA = makeCredentials(service: service, scope: "identity-A", markers: markers)
#expect(identityA.adoptLegacyCredential(for: account) == true)
// Someone else signed in on an older release, which rewrote the
// unscoped key. That login is live on their devices.
seedLegacyItem(service: service, instanceID: instanceID, value: "someone-elses-secret")
identityA.deleteLegacyCredentialIfOwned(for: account)
#expect(legacyValue(service: service, instanceID: instanceID) == "someone-elses-secret")
identityA.deleteCredential(for: account)
KeychainCredentialStore(service: service)
.delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID))
}
@Test("Adoption in one backend does not suppress the other's offer")
func markersAreNamespacedByService() {
// Both credential managers share one marker store, and an instance UUID
// can outlive a change of backend.
let invidiousService = makeService()
let pipedService = makeService()
let markers = makeMarkers()
let instanceID = UUID()
let account = Account.migrated(from: instanceID)
seedLegacyItem(service: invidiousService, instanceID: instanceID, value: "invidious-sid")
seedLegacyItem(service: pipedService, instanceID: instanceID, value: "piped-token")
let invidious = makeCredentials(service: invidiousService, scope: "identity-A", markers: markers)
let piped = makeCredentials(service: pipedService, scope: "identity-A", markers: markers)
#expect(invidious.adoptLegacyCredential(for: account) == true)
// Piped's own legacy item is untouched and still offered.
#expect(piped.hasAdoptableLegacyCredential(for: account) == true)
#expect(piped.adoptLegacyCredential(for: account) == true)
#expect(piped.credential(for: account) == "piped-token")
#expect(invidious.credential(for: account) == "invidious-sid")
// Each deletion consults its own marker and its own secret.
invidious.deleteLegacyCredentialIfOwned(for: account)
#expect(legacyValue(service: invidiousService, instanceID: instanceID) == nil)
#expect(legacyValue(service: pipedService, instanceID: instanceID) == "piped-token")
piped.deleteLegacyCredentialIfOwned(for: account)
#expect(legacyValue(service: pipedService, instanceID: instanceID) == nil)
invidious.deleteCredential(for: account)
piped.deleteCredential(for: account)
}
@Test("Adoption is refused while the identity scope is unresolved")
func adoptionRefusedWhenUnresolved() {
let service = makeService()
let instanceID = UUID()
let account = Account.migrated(from: instanceID)
seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret")
let credentials = AccountKeychainCredentials(
service: service,
credentialNoun: "credential",
identityScope: CredentialIdentityScope(fixedScope: nil),
markers: makeMarkers()
)
#expect(credentials.hasAdoptableLegacyCredential(for: account) == false)
#expect(credentials.adoptLegacyCredential(for: account) == false)
KeychainCredentialStore(service: service)
.delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID))
}
}
@Suite("Credential identity scope resolution")
@MainActor
struct CredentialIdentityScopeTests {
private func makeDefaults() -> UserDefaults {
UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!
}
/// Mutable identity + resolver so a switch can be staged mid-resolution.
private final class Harness: @unchecked Sendable {
var fingerprint: String? = "identity-A"
var resultsByFingerprint: [String: String] = [:]
var failuresRemaining = 0
var availability: CloudKitIdentity.AccountAvailability = .available
/// User record returned when there is no ubiquity token to key on.
var tokenlessRecord: String?
var onResolveStart: (() -> Void)?
var switchTask: Task<Void, Never>?
func resolve() async -> String? {
// Snapshotted before the hook runs, so a switch staged inside it
// yields a genuinely stale result rather than the new value.
let tokenless = tokenlessRecord
onResolveStart?()
if failuresRemaining > 0 {
failuresRemaining -= 1
return nil
}
guard let fingerprint else { return tokenless }
return resultsByFingerprint[fingerprint]
}
}
@Test("A transient failure is retried rather than disabling credentials forever")
func transientFailureIsRetried() async {
let harness = Harness()
harness.resultsByFingerprint = ["identity-A": "_recordA"]
harness.failuresRemaining = 2
let scope = CredentialIdentityScope(
defaults: makeDefaults(),
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { await harness.resolve() },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
#expect(scope.isResolved == false)
await scope.resolve()
#expect(scope.current == "_recordA")
}
@Test("Exhausted attempts are retried on foreground activation")
func exhaustedAttemptsRetryOnForeground() async {
let harness = Harness()
harness.resultsByFingerprint = ["identity-A": "_recordA"]
harness.failuresRemaining = 99 // every attempt fails
let scope = CredentialIdentityScope(
defaults: makeDefaults(),
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { await harness.resolve() },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
await scope.resolve()
#expect(scope.isResolved == false)
// Connectivity returns; the app comes back to the foreground.
harness.failuresRemaining = 0
await scope.resolveIfNeeded()
#expect(scope.current == "_recordA")
}
@Test("An identity switch mid-resolution resolves the new identity")
func switchDuringResolutionResolvesNewIdentity() async {
let harness = Harness()
harness.resultsByFingerprint = ["identity-A": "_recordA", "identity-B": "_recordB"]
let scope = CredentialIdentityScope(
defaults: makeDefaults(),
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { await harness.resolve() },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
// The Apple account switches while A's lookup is in flight — exactly
// what the notification observer does, only deterministically.
harness.onResolveStart = { [weak scope] in
harness.onResolveStart = nil
harness.fingerprint = "identity-B"
MainActor.assumeIsolated {
scope?.adoptCurrentIdentity()
harness.switchTask = Task { await scope?.resolve() }
}
}
await scope.resolve()
await harness.switchTask?.value
// A's result must be discarded, and B must not be left unresolved.
#expect(scope.current != "_recordA")
#expect(scope.current == "_recordB")
}
@Test("A missing ubiquity token alone never scopes to local")
func missingTokenDoesNotImplySignedOut() async {
// iCloud Drive off while still signed in: Foundation returns no token,
// but the account is real. Scoping to local here would put two
// different Apple accounts in one namespace.
let harness = Harness()
harness.fingerprint = nil
harness.availability = .available
harness.resultsByFingerprint = [:]
let scope = CredentialIdentityScope(
defaults: makeDefaults(),
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { "_recordA" },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
#expect(scope.current == nil)
await scope.resolve()
#expect(scope.current == "_recordA")
#expect(scope.current != CredentialIdentityScope.localScope)
}
@Test("Signing out scopes to local, confirmed by CloudKit")
func signedOutUsesLocalScope() async {
let harness = Harness()
harness.fingerprint = nil
harness.availability = .noAccount
let scope = CredentialIdentityScope(
defaults: makeDefaults(),
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { await harness.resolve() },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
// Not assumed up front — only CloudKit can tell "signed out" from
// "token unavailable".
#expect(scope.current == nil)
await scope.resolve()
#expect(scope.current == CredentialIdentityScope.localScope)
}
@Test("A tokenless scope is re-pointed when the account changes silently")
func tokenlessScopeIsRevalidated() async {
// iCloud Drive off: the token stays nil across the switch, so nothing
// local changes and no ubiquity notification fires.
let harness = Harness()
harness.fingerprint = nil
harness.availability = .available
harness.tokenlessRecord = "_recordA"
let scope = CredentialIdentityScope(
defaults: makeDefaults(),
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { harness.tokenlessRecord },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
await scope.resolve()
#expect(scope.current == "_recordA")
// The user switches to account B and returns to the app.
harness.tokenlessRecord = "_recordB"
await scope.resolveIfNeeded()
#expect(scope.current == "_recordB")
}
@Test("A tokenless scope falls back to local once the account is gone")
func tokenlessScopeFallsBackOnSignOut() async {
let harness = Harness()
harness.fingerprint = nil
harness.availability = .available
let scope = CredentialIdentityScope(
defaults: makeDefaults(),
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { "_recordA" },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
await scope.resolve()
#expect(scope.current == "_recordA")
harness.availability = .noAccount
await scope.resolveIfNeeded()
#expect(scope.current == CredentialIdentityScope.localScope)
}
@Test("A token-derived scope is not re-checked on every activation")
func tokenDerivedScopeIsStable() async {
let harness = Harness()
harness.resultsByFingerprint = ["identity-A": "_recordA"]
let scope = CredentialIdentityScope(
defaults: makeDefaults(),
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { await harness.resolve() },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
await scope.resolve()
#expect(scope.current == "_recordA")
// A token-backed scope changes only via the notification, so a
// foreground pass must leave it exactly as it was.
harness.resultsByFingerprint = ["identity-A": "_somethingElse"]
await scope.resolveIfNeeded()
#expect(scope.current == "_recordA")
}
@Test("A tokenless scope is dropped, not served, while it is unconfirmed")
func tokenlessScopeIsNotServedWhileUnconfirmed() async {
let harness = Harness()
harness.fingerprint = nil
harness.availability = .available
harness.tokenlessRecord = "_recordA"
let scope = CredentialIdentityScope(
defaults: makeDefaults(),
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { harness.tokenlessRecord },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
await scope.resolve()
#expect(scope.current == "_recordA")
// Foregrounding with CloudKit unreachable must not keep serving A —
// the account may have changed while the app was suspended.
harness.availability = .indeterminate
await scope.resolveIfNeeded()
#expect(scope.current == nil)
}
@Test("A stale tokenless lookup is refused after an account change")
func staleTokenlessLookupIsRefused() async {
// Both before and after the switch the fingerprint is nil, so only a
// generation counter can tell the two lookups apart.
let harness = Harness()
harness.fingerprint = nil
harness.availability = .available
harness.tokenlessRecord = "_recordA"
let scope = CredentialIdentityScope(
defaults: makeDefaults(),
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { await harness.resolve() },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
// CKAccountChanged lands while A's lookup is in flight.
harness.onResolveStart = { [weak scope] in
harness.onResolveStart = nil
MainActor.assumeIsolated {
harness.tokenlessRecord = "_recordB"
scope?.identityDidChange(source: "test")
}
}
await scope.resolve()
// The decisive assertion: A's result must not be accepted just because
// nil == nil on both sides of the switch.
#expect(scope.current != "_recordA")
// Then the replay lands on B (driven here rather than awaiting the
// observer's detached task).
await scope.resolveIfNeeded()
#expect(scope.current == "_recordB")
}
@Test("An account change restores the retry budget")
func identityChangeResetsAttemptBudget() async {
let harness = Harness()
harness.fingerprint = nil
harness.availability = .available
harness.tokenlessRecord = "_recordA"
harness.failuresRemaining = 99 // exhaust every attempt for this identity
let scope = CredentialIdentityScope(
defaults: makeDefaults(),
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { await harness.resolve() },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
await scope.resolve()
#expect(scope.current == nil)
// A different account signs in. Its lookup must not inherit an
// exhausted budget and give up without trying.
harness.failuresRemaining = 0
harness.tokenlessRecord = "_recordB"
scope.identityDidChange(source: "test")
await scope.resolve()
#expect(scope.current == "_recordB")
}
@Test("An undeterminable account status leaves the scope unresolved")
func indeterminateStatusStaysUnresolved() async {
let harness = Harness()
harness.fingerprint = nil
harness.availability = .indeterminate
let scope = CredentialIdentityScope(
defaults: makeDefaults(),
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { "_shouldNotBeUsed" },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
await scope.resolve()
#expect(scope.current == nil)
}
@Test("A known Apple account resolves synchronously on relaunch")
func cachedScopeResolvesSynchronously() async {
let defaults = makeDefaults()
let harness = Harness()
harness.resultsByFingerprint = ["identity-A": "_recordA"]
let first = CredentialIdentityScope(
defaults: defaults,
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { await harness.resolve() },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
await first.resolve()
#expect(first.current == "_recordA")
// Relaunch: no network involved this time.
let second = CredentialIdentityScope(
defaults: defaults,
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { nil },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
#expect(second.current == "_recordA")
}
}
@Suite("Migrated account identity")
@MainActor
struct MigratedAccountTests {
@Test("A migrated account reuses the instance UUID")
func migratedAccountReusesInstanceID() {
let instanceID = UUID()
let account = Account.migrated(from: instanceID)
// Determinism across devices: two devices migrating the same instance
// independently produce the identical record name, which merges
// per-record instead of duplicating.
#expect(account.id == instanceID)
#expect(account.instanceID == instanceID)
#expect(account.isMigrated)
#expect(Account.migrated(from: instanceID).id == account.id)
}
@Test("A migrated account carries an epoch-old updatedAt")
func migratedAccountLosesConflicts() {
let migrated = Account.migrated(from: UUID())
let explicit = Account(instanceID: UUID())
// Any explicit user edit on any device must outrank a migration write,
// regardless of arrival order.
#expect(migrated.updatedAt < explicit.updatedAt)
}
@Test("A new account gets a fresh UUID")
func newAccountsAreDistinct() {
let instanceID = UUID()
let first = Account(instanceID: instanceID)
let second = Account(instanceID: instanceID)
#expect(first.id != second.id)
#expect(!first.isMigrated)
}
}