mirror of
https://github.com/yattee/yattee.git
synced 2026-10-27 15:07:18 +00:00
Stop a missing ubiquity token from meaning "signed out"
Third review pass. adoptCurrentIdentity() treated a nil ubiquityIdentityToken as sign-out and scoped credentials to "local". But Foundation also returns nil when iCloud Drive is unavailable or switched off, and Yattee declares only the CloudKit service with no ubiquity container, so the token tracks iCloud Drive state rather than the account. In that configuration two different signed-in Apple accounts both resolved to "local" and shared one credential namespace - since Keychain items survive an account switch, account B could then read account A's retained credential. That is the precise failure this whole change exists to prevent. A nil token now means unresolved, and CloudKit's account status settles it during resolution: noAccount scopes to local, available proceeds to the user-record lookup, anything else stays unresolved and retries. A scope resolved without a token is not cached, because there is no key that could be trusted to mean this account. Adoption markers are now namespaced by Keychain service. Both credential managers share one marker store and an instance UUID can outlive a change of backend, so adopting an Invidious credential could suppress the Piped offer for the same instance and point deletion at the wrong secret. Writing the account-status branch also surfaced a Swift trap: an unqualified `break` inside the switch left only the switch and fell through to the user-record lookup, resolving a scope on an undetermined account status. The loop is labelled now, and the test that caught it is included.
This commit is contained in:
@@ -321,6 +321,42 @@ struct LegacyCredentialAdoptionTests {
|
||||
.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()
|
||||
@@ -355,6 +391,7 @@ struct CredentialIdentityScopeTests {
|
||||
var fingerprint: String? = "identity-A"
|
||||
var resultsByFingerprint: [String: String] = [:]
|
||||
var failuresRemaining = 0
|
||||
var availability: CloudKitIdentity.AccountAvailability = .available
|
||||
var onResolveStart: (() -> Void)?
|
||||
var switchTask: Task<Void, Never>?
|
||||
|
||||
@@ -379,6 +416,7 @@ struct CredentialIdentityScopeTests {
|
||||
defaults: makeDefaults(),
|
||||
fingerprintProvider: { harness.fingerprint },
|
||||
resolveUserRecordID: { await harness.resolve() },
|
||||
accountAvailabilityProvider: { harness.availability },
|
||||
retryDelay: { _ in }
|
||||
)
|
||||
|
||||
@@ -397,6 +435,7 @@ struct CredentialIdentityScopeTests {
|
||||
defaults: makeDefaults(),
|
||||
fingerprintProvider: { harness.fingerprint },
|
||||
resolveUserRecordID: { await harness.resolve() },
|
||||
accountAvailabilityProvider: { harness.availability },
|
||||
retryDelay: { _ in }
|
||||
)
|
||||
|
||||
@@ -418,6 +457,7 @@ struct CredentialIdentityScopeTests {
|
||||
defaults: makeDefaults(),
|
||||
fingerprintProvider: { harness.fingerprint },
|
||||
resolveUserRecordID: { await harness.resolve() },
|
||||
accountAvailabilityProvider: { harness.availability },
|
||||
retryDelay: { _ in }
|
||||
)
|
||||
|
||||
@@ -440,21 +480,69 @@ struct CredentialIdentityScopeTests {
|
||||
#expect(scope.current == "_recordB")
|
||||
}
|
||||
|
||||
@Test("Signing out scopes to local without a lookup")
|
||||
func signedOutUsesLocalScope() {
|
||||
@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("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()
|
||||
@@ -465,6 +553,7 @@ struct CredentialIdentityScopeTests {
|
||||
defaults: defaults,
|
||||
fingerprintProvider: { harness.fingerprint },
|
||||
resolveUserRecordID: { await harness.resolve() },
|
||||
accountAvailabilityProvider: { harness.availability },
|
||||
retryDelay: { _ in }
|
||||
)
|
||||
await first.resolve()
|
||||
@@ -475,6 +564,7 @@ struct CredentialIdentityScopeTests {
|
||||
defaults: defaults,
|
||||
fingerprintProvider: { harness.fingerprint },
|
||||
resolveUserRecordID: { nil },
|
||||
accountAvailabilityProvider: { harness.availability },
|
||||
retryDelay: { _ in }
|
||||
)
|
||||
#expect(second.current == "_recordA")
|
||||
|
||||
Reference in New Issue
Block a user