diff --git a/Yattee/Services/CloudKit/CloudKitIdentity.swift b/Yattee/Services/CloudKit/CloudKitIdentity.swift index a05c176d..05fe4c9f 100644 --- a/Yattee/Services/CloudKit/CloudKitIdentity.swift +++ b/Yattee/Services/CloudKit/CloudKitIdentity.swift @@ -32,12 +32,22 @@ enum CloudKitIdentity { /// carries no account information — it only supports equality comparison /// against a previously persisted fingerprint. static var ubiquityFingerprint: String? { - guard let token = FileManager.default.ubiquityIdentityToken, - let data = try? NSKeyedArchiver.archivedData( - withRootObject: token, - requiringSecureCoding: true - ) - else { return nil } + guard let token = FileManager.default.ubiquityIdentityToken else { return nil } + + // The token is only guaranteed to be `NSCoding` — Foundation types it + // as `NSCoding & NSCopying & NSObjectProtocol`, with no + // `NSSecureCoding`. Archiving with `requiringSecureCoding: true` would + // therefore throw on a conforming implementation, and a `try?` there + // would silently yield nil: the scope would lose its cache, forcing a + // CloudKit lookup on every launch and leaving credentials unavailable + // offline. Secure coding buys nothing here regardless — the archive is + // only ever hashed, never decoded. + let archiver = NSKeyedArchiver(requiringSecureCoding: false) + archiver.encode(token, forKey: NSKeyedArchiveRootObjectKey) + archiver.finishEncoding() + + let data = archiver.encodedData + guard !data.isEmpty else { return nil } return shortDigest(of: data) } diff --git a/Yattee/Services/Credentials/CredentialIdentityScope.swift b/Yattee/Services/Credentials/CredentialIdentityScope.swift index 866fd203..faf00982 100644 --- a/Yattee/Services/Credentials/CredentialIdentityScope.swift +++ b/Yattee/Services/Credentials/CredentialIdentityScope.swift @@ -169,6 +169,12 @@ final class CredentialIdentityScope { // generation are refused even when the fingerprint looks unchanged. identityGeneration += 1 + // The budget belongs to the identity being resolved, so it resets on + // every change — not only the tokenless branch below. A previous + // identity that exhausted its attempts would otherwise leave the new + // one with none, and its queued resolve() would do no work at all. + resolutionAttempts = 0 + // A tokenless scope cannot be re-derived by `adoptCurrentIdentity()` // (there is no fingerprint to compare), so drop it outright: continuing // to serve it would hand the previous account's credentials to the new @@ -176,7 +182,6 @@ final class CredentialIdentityScope { if resolvedWithoutToken { state = .unresolved resolvedWithoutToken = false - resolutionAttempts = 0 } adoptCurrentIdentity() diff --git a/YatteeTests/AccountCredentialsTests.swift b/YatteeTests/AccountCredentialsTests.swift index b793cea5..8a317d3f 100644 --- a/YatteeTests/AccountCredentialsTests.swift +++ b/YatteeTests/AccountCredentialsTests.swift @@ -664,6 +664,35 @@ struct CredentialIdentityScopeTests { #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()