Order tokenless scope resolution with an identity generation

Sixth review pass, both findings in the tokenless path the fourth pass
introduced.

The staleness guard compared fingerprints, which is useless when there is
no ubiquity token: nil before the switch and nil after it, so a user-record
lookup started under account A was accepted as current after CKAccountChanged
had already announced B. The scope then read as resolved, the pending-resolve
replay was skipped because it only runs while unresolved, and B was left
pointed at A's credential namespace. Every async path now captures a
generation counter that every observed account change bumps, and refuses to
apply a result across it.

Foreground revalidation also kept serving the old tokenless scope across
both awaits, and indefinitely when the account status came back
undetermined - so a switch that happened while the app was suspended could
hand A's retained credentials to B. The scope is now dropped before the
confirmation rather than compared after it, which also let the bespoke
revalidation logic collapse into resolve(), where the signed-out,
signed-in and undetermined cases are already handled. A tokenless setup
briefly reports "can't tell yet" on each activation; the tri-state UI
covers that, and the alternative traded a real cross-account disclosure for
the convenience.
This commit is contained in:
Arkadiusz Fal
2026-08-24 19:59:25 +02:00
parent 2d23820e14
commit 00a298b09f
2 changed files with 103 additions and 31 deletions

View File

@@ -398,12 +398,15 @@ struct CredentialIdentityScopeTests {
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 nil }
guard let fingerprint else { return tokenless }
return resultsByFingerprint[fingerprint]
}
}
@@ -598,6 +601,69 @@ struct CredentialIdentityScopeTests {
#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 undeterminable account status leaves the scope unresolved")
func indeterminateStatusStaysUnresolved() async {
let harness = Harness()