mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Fourth review pass, on a hole the previous commit opened. Making a nil ubiquity token mean "unresolved" fixed the shared-local-scope collision, but left a scope resolved from CloudKit alone with nothing local to watch: with iCloud Drive off the token stays nil across a switch from account A to B, so NSUbiquityIdentityDidChange never fires and the scope kept pointing at A - and its retained credentials with it - until relaunch. Two additions. CKAccountChanged is now observed alongside the ubiquity notification, and a tokenless scope is dropped outright when either fires because there is no fingerprint to re-derive it from. Foreground activation also revalidates a tokenless scope against CloudKit, covering a switch whose notification never arrived; it compares before replacing so the common case never blanks out credentials, and an unreachable account leaves the scope standing rather than flapping on every transient failure. A token-derived scope is untouched by all of this - it changes only via the notification, and a test pins that down.
684 lines
28 KiB
Swift
684 lines
28 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
|
|
) -> AccountKeychainCredentials {
|
|
AccountKeychainCredentials(
|
|
service: service,
|
|
credentialNoun: "credential",
|
|
identityScope: CredentialIdentityScope(fixedScope: scope),
|
|
markers: markers
|
|
)
|
|
}
|
|
|
|
@Test("A legacy credential is never adopted automatically")
|
|
func legacyCredentialIsNotAutoAdopted() {
|
|
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())
|
|
|
|
// Reading never falls back to the unscoped item — that is the whole
|
|
// point of scoping, since a retained item proves nothing about who owns it.
|
|
#expect(credentials.credential(for: account) == nil)
|
|
#expect(credentials.isLoggedIn(for: account) == false)
|
|
// But it is offered as an explicit action.
|
|
#expect(credentials.hasAdoptableLegacyCredential(for: account) == true)
|
|
|
|
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? {
|
|
onResolveStart?()
|
|
if failuresRemaining > 0 {
|
|
failuresRemaining -= 1
|
|
return nil
|
|
}
|
|
guard let fingerprint else { return nil }
|
|
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("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)
|
|
}
|
|
}
|