mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Let the account that signs in claim what was made signed out
This commit is contained in:
@@ -1022,6 +1022,222 @@ final class AccountsManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Local-namespace import
|
||||
|
||||
/// What the `local` namespace still holds, read without loading it.
|
||||
///
|
||||
/// Only the three things an identity may take over. The deletion facts
|
||||
/// stored beside them are deliberately absent: a deletion made while signed
|
||||
/// out is a local fact and must never be published into an Apple account's
|
||||
/// synced set (decision 3).
|
||||
struct LocalNamespaceSnapshot {
|
||||
var accounts: [Account] = []
|
||||
var migratedInstanceIDs: Set<UUID> = []
|
||||
/// The context chosen there, or `nil` when none ever was.
|
||||
var storedContext: StoredDataContext?
|
||||
|
||||
/// Whether there is anything worth sweeping. The migration markers
|
||||
/// alone are not: they are copied as a side effect of importing the
|
||||
/// accounts they describe (decision 4), and unioning them on their own
|
||||
/// every activation would write for nothing.
|
||||
var hasContent: Bool { !accounts.isEmpty || storedContext != nil }
|
||||
}
|
||||
|
||||
/// What became of a record offered to the active identity.
|
||||
enum ImportOutcome {
|
||||
case imported
|
||||
case mergedIntoExisting
|
||||
case rejectedByTombstone
|
||||
/// Nothing was decided, because the namespace this manager holds is not
|
||||
/// the one being imported into. The caller must leave the local record
|
||||
/// exactly where it is.
|
||||
case deferred
|
||||
}
|
||||
|
||||
/// Whether the local namespace may be imported into what is loaded now.
|
||||
///
|
||||
/// Three fences in one place. The first is the one `save()` uses: the scope
|
||||
/// moved and the reload has not refilled memory yet, so anything decided
|
||||
/// here would be one identity's data merged into another's. The second is
|
||||
/// decision 9 — a sideloaded or signed-out install is permanently `local`,
|
||||
/// where the sweep would import a namespace into itself. The third is
|
||||
/// decision 10: `unresolved` is a holding pen that gets purged, never a
|
||||
/// destination.
|
||||
private var canImportIntoCurrentNamespace: Bool {
|
||||
guard identityScope.isResolved else { return false }
|
||||
guard namespace != CredentialIdentityScope.localScope else { return false }
|
||||
return loadedNamespace == namespace
|
||||
}
|
||||
|
||||
/// Reads the `local` namespace without disturbing it.
|
||||
func localNamespaceSnapshot() -> LocalNamespaceSnapshot {
|
||||
guard canImportIntoCurrentNamespace else { return LocalNamespaceSnapshot() }
|
||||
|
||||
let local = CredentialIdentityScope.localScope
|
||||
var snapshot = LocalNamespaceSnapshot()
|
||||
|
||||
if let data = defaults.data(forKey: key("accounts", in: local)),
|
||||
let decoded = try? JSONDecoder().decode([Account].self, from: data) {
|
||||
snapshot.accounts = decoded
|
||||
}
|
||||
|
||||
if let ids = defaults.array(forKey: key("migrated", in: local)) as? [String] {
|
||||
snapshot.migratedInstanceIDs = Set(ids.compactMap(UUID.init(uuidString:)))
|
||||
}
|
||||
|
||||
if defaults.bool(forKey: key("hasContext", in: local)),
|
||||
let data = defaults.data(forKey: key("context", in: local)),
|
||||
let decoded = try? JSONDecoder().decode(StoredDataContext.self, from: data) {
|
||||
snapshot.storedContext = decoded
|
||||
}
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// Offers a record from the local namespace to the active identity.
|
||||
@discardableResult
|
||||
func adoptImportedAccount(_ account: Account) -> ImportOutcome {
|
||||
guard canImportIntoCurrentNamespace else { return .deferred }
|
||||
|
||||
// Decision 5, first rule: this identity deleted the account, and the
|
||||
// IDs are deterministic enough for that to be the same account rather
|
||||
// than a coincidence — a migrated one reuses its instance's UUID. A
|
||||
// stale local copy must never resurrect it. The instance's own deletion
|
||||
// says the same thing about everything that lived on it, and arrives as
|
||||
// its own permanent fact.
|
||||
guard !deletedAccountIDs.contains(account.id),
|
||||
!deletedInstanceIDs.contains(account.instanceID) else {
|
||||
LoggingService.shared.info(
|
||||
"Refusing to import an account this identity has deleted",
|
||||
category: .general,
|
||||
details: "accountID=\(account.id)"
|
||||
)
|
||||
return .rejectedByTombstone
|
||||
}
|
||||
|
||||
// Decision 5, second rule: the identity's own copy stands. A strictly
|
||||
// newer label from the local copy is applied through the ordinary edit
|
||||
// path, so it is marked dirty and ordered by `updatedAt` like any other
|
||||
// change — nothing else about the record is taken.
|
||||
if let existing = self.account(id: account.id) {
|
||||
if account.updatedAt > existing.updatedAt, account.displayName != existing.displayName {
|
||||
updateDisplayName(account.displayName, for: account.id)
|
||||
}
|
||||
return .mergedIntoExisting
|
||||
}
|
||||
|
||||
// Decision 5, third rule: imported verbatim. `updatedAt` and `dateAdded`
|
||||
// are preserved so conflict resolution everywhere else orders this
|
||||
// record by when the user actually made it, not by when it moved.
|
||||
accounts.append(account)
|
||||
pendingUploads.insert(account.id)
|
||||
save()
|
||||
|
||||
LoggingService.shared.info(
|
||||
"Imported an account created while signed out into the active identity",
|
||||
category: .general,
|
||||
details: "accountID=\(account.id)"
|
||||
)
|
||||
|
||||
NotificationCenter.default.post(name: .accountsDidChange, object: nil)
|
||||
return .imported
|
||||
}
|
||||
|
||||
/// Takes on the local namespace's migration markers (decision 4).
|
||||
///
|
||||
/// Copied rather than moved: the local set stays where it is, so a later
|
||||
/// sign-out cannot let `reconcileLegacyAccounts()` synthesize the same
|
||||
/// account again from an `usesAccountLogin` flag that is still true — every
|
||||
/// cycle would otherwise mint another import.
|
||||
func adoptImportedMigratedInstances(_ ids: Set<UUID>) {
|
||||
guard canImportIntoCurrentNamespace else { return }
|
||||
|
||||
let added = ids.subtracting(migratedInstanceIDs)
|
||||
guard !added.isEmpty else { return }
|
||||
|
||||
migratedInstanceIDs.formUnion(added)
|
||||
// A marker suppresses synthesis on every other device, so it may only be
|
||||
// published for an instance whose account this identity actually holds.
|
||||
// Publishing it for one whose account was refused would leave that
|
||||
// instance's pre-revamp login unrepresented everywhere.
|
||||
for instanceID in added where !accounts(forInstance: instanceID).isEmpty {
|
||||
pendingMarkerUploads.insert(instanceID)
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
/// Carries the local namespace's context over, if this identity has none.
|
||||
///
|
||||
/// Epoch-stamped through the same rule the legacy-context migration uses:
|
||||
/// a selection made while signed out is a guess at what the user wants under
|
||||
/// this Apple account, so any explicit or synced choice outranks it whichever
|
||||
/// arrives first (decision 6). Held back from the server by the existing
|
||||
/// `canUploadContext` gate until the account it names is confirmed.
|
||||
func adoptImportedContext(_ stored: StoredDataContext) {
|
||||
guard canImportIntoCurrentNamespace else { return }
|
||||
guard !hasStoredContext else { return }
|
||||
|
||||
storedContext = .migrated(stored.context)
|
||||
hasStoredContext = true
|
||||
storedContextIsRepair = false
|
||||
pendingContextUpload = true
|
||||
save()
|
||||
|
||||
LoggingService.shared.info(
|
||||
"Carried a data context chosen while signed out into the active identity",
|
||||
category: .general
|
||||
)
|
||||
|
||||
NotificationCenter.default.post(name: .dataContextDidChange, object: nil)
|
||||
}
|
||||
|
||||
/// Removes imported records from the local namespace (decision 2).
|
||||
///
|
||||
/// Move semantics: a local record is only ever passed here once it has
|
||||
/// landed under the identity — or was correctly refused — and its credential
|
||||
/// has been dealt with, so a sign-out afterwards shows only what was never
|
||||
/// imported. The local `migrated` and `deleted*` keys are never touched:
|
||||
/// they are retained on purpose (decisions 3 and 4).
|
||||
func pruneLocalNamespace(removingAccountIDs removed: Set<UUID>, clearContext: Bool) {
|
||||
guard canImportIntoCurrentNamespace else { return }
|
||||
|
||||
let local = CredentialIdentityScope.localScope
|
||||
|
||||
if !removed.isEmpty {
|
||||
var stored: [Account] = []
|
||||
if let data = defaults.data(forKey: key("accounts", in: local)),
|
||||
let decoded = try? JSONDecoder().decode([Account].self, from: data) {
|
||||
stored = decoded
|
||||
}
|
||||
|
||||
let remaining = stored.filter { !removed.contains($0.id) }
|
||||
if remaining.isEmpty {
|
||||
defaults.removeObject(forKey: key("accounts", in: local))
|
||||
} else if let data = try? JSONEncoder().encode(remaining) {
|
||||
defaults.set(data, forKey: key("accounts", in: local))
|
||||
}
|
||||
}
|
||||
|
||||
guard clearContext else { return }
|
||||
for name in ["context", "hasContext", "contextIsRepair"] {
|
||||
defaults.removeObject(forKey: key(name, in: local))
|
||||
}
|
||||
}
|
||||
|
||||
/// Called once the in-memory state describes an identity namespace that is
|
||||
/// ready to be read and written.
|
||||
///
|
||||
/// A hook rather than another `.credentialIdentityScopeDidChange` observer
|
||||
/// because the import sweep has to run *after* this manager has reloaded,
|
||||
/// reconciled and announced — and relying on notification observer
|
||||
/// registration order for that would be silently fragile.
|
||||
///
|
||||
/// Deliberately not fired for the load this manager performs in its own
|
||||
/// initializer: nothing can have installed a hook by then. A launch that
|
||||
/// resolves its identity from cache never changes scope afterwards, so the
|
||||
/// app invokes the sweep once itself after wiring completes.
|
||||
@ObservationIgnored var onNamespaceActivated: (@MainActor () -> Void)?
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// Storage keys are namespaced by identity so a switch of Apple account
|
||||
@@ -1052,6 +1268,12 @@ final class AccountsManager {
|
||||
@ObservationIgnored private(set) var loadedNamespace = ""
|
||||
|
||||
private func key(_ name: String) -> String {
|
||||
key(name, in: namespace)
|
||||
}
|
||||
|
||||
/// A key in a namespace other than the active one — the import sweep reads
|
||||
/// and prunes `local` while a resolved identity is loaded.
|
||||
private func key(_ name: String, in namespace: String) -> String {
|
||||
"accounts.\(namespace).\(name)"
|
||||
}
|
||||
|
||||
@@ -1225,6 +1447,11 @@ final class AccountsManager {
|
||||
// Anything keyed to the selection — the legacy setting, the feed cache —
|
||||
// would otherwise keep serving the previous Apple account's data.
|
||||
NotificationCenter.default.post(name: .dataContextDidChange, object: nil)
|
||||
|
||||
// Last, and only once everything above has settled: whoever imports the
|
||||
// signed-out namespace needs this manager to already describe the
|
||||
// identity it is importing into.
|
||||
onNamespaceActivated?()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ final class AppEnvironment {
|
||||
let credentialIdentityScope: CredentialIdentityScope
|
||||
let accountsManager: AccountsManager
|
||||
let accountSyncCoordinator: AccountSyncCoordinator
|
||||
/// Hands accounts added while signed out to whichever identity signs in.
|
||||
let localNamespaceImporter: LocalNamespaceImporter
|
||||
let homeInstanceCache: HomeInstanceCache
|
||||
let invidiousAPI: InvidiousAPI
|
||||
let pipedAPI: PipedAPI
|
||||
@@ -218,6 +220,37 @@ final class AppEnvironment {
|
||||
let accounts = AccountsManager(identityScope: identityScope, instancesManager: instances)
|
||||
self.accountsManager = accounts
|
||||
|
||||
// Accounts created while signed out live in the `local` namespace, and
|
||||
// nothing reads that namespace once an identity resolves. This is what
|
||||
// moves them — records, credentials and the chosen context — into the
|
||||
// Apple account that signs in.
|
||||
self.localNamespaceImporter = LocalNamespaceImporter(
|
||||
accountsManager: accounts,
|
||||
identityScope: identityScope,
|
||||
credentialStores: { [weak accounts, weak invidiousCreds, weak pipedCreds] account in
|
||||
// Same resolution as `accountCredentialsManager(for:)`, except
|
||||
// that an instance which has not synced yet is not an answer:
|
||||
// the scoped key is the account ID either way, so both stores
|
||||
// are asked and at most one of them holds anything.
|
||||
var stores: [any AccountCredentialsManager] = []
|
||||
|
||||
guard let type = accounts?.instance(for: account)?.type else {
|
||||
if let invidiousCreds { stores.append(invidiousCreds) }
|
||||
if let pipedCreds { stores.append(pipedCreds) }
|
||||
return stores
|
||||
}
|
||||
|
||||
switch type {
|
||||
case .invidious: if let invidiousCreds { stores.append(invidiousCreds) }
|
||||
case .piped: if let pipedCreds { stores.append(pipedCreds) }
|
||||
// The slot a future account-keyed store lands in, exactly as in
|
||||
// `accountCredentialsManager(for:)`.
|
||||
default: break
|
||||
}
|
||||
return stores
|
||||
}
|
||||
)
|
||||
|
||||
// Initialize Invidious API (used by SubscriptionService and SubscriptionFeedCache)
|
||||
let invidiousAPI = InvidiousAPI(httpClient: client)
|
||||
self.invidiousAPI = invidiousAPI
|
||||
@@ -518,6 +551,17 @@ final class AppEnvironment {
|
||||
self?.refreshAccountStatuses()
|
||||
}
|
||||
|
||||
// Every activation of an identity namespace sweeps the signed-out one.
|
||||
// A hook rather than another observer of the scope change: the sweep
|
||||
// must run after the manager has reloaded, and observer registration
|
||||
// order is not something to depend on for that. Statuses are recomputed
|
||||
// only when something moved — a credential that just arrived under this
|
||||
// identity is what decides whether its account reads as signed in.
|
||||
accounts.onNamespaceActivated = { [weak self] in
|
||||
guard let self, self.localNamespaceImporter.sweepIfNeeded() else { return }
|
||||
self.refreshAccountStatuses()
|
||||
}
|
||||
|
||||
accountSync.onAccountDeleted = { [weak self] accountID, account in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -592,6 +636,14 @@ final class AppEnvironment {
|
||||
// it runs again whenever instances arrive from iCloud.
|
||||
accounts.reconcileLegacyAccounts()
|
||||
accounts.migrateLegacyContextIfNeeded()
|
||||
|
||||
// A launch that restores its identity scope from cache resolves inside
|
||||
// the initializer above, so no scope change will ever be announced for
|
||||
// it and `onNamespaceActivated` never fires. Invoked once here so that
|
||||
// path sweeps too; the sweep is idempotent, so an identity that does
|
||||
// arrive later simply finds nothing left.
|
||||
localNamespaceImporter.sweepIfNeeded()
|
||||
|
||||
refreshAccountStatuses()
|
||||
|
||||
// Seeded once the statuses it depends on are known, so launching does
|
||||
|
||||
@@ -55,6 +55,26 @@ protocol AccountCredentialsManager: AnyObject, Observable {
|
||||
func isLoggedIn(for account: Account) -> Bool
|
||||
func refreshLoginStatus(for account: Account)
|
||||
|
||||
// MARK: - Identity migration
|
||||
|
||||
/// Moves an account's credential and username from `sourceScope` into the
|
||||
/// current one, for a record being imported out of the `local` namespace
|
||||
/// when an Apple account claims it.
|
||||
///
|
||||
/// - Returns: `true` when `sourceScope` no longer holds anything for this
|
||||
/// account. `false` means the move failed and left the source intact, so
|
||||
/// the record it belongs to must stay where it is and be retried.
|
||||
@discardableResult
|
||||
func migrateCredential(forAccountID accountID: UUID, fromScope sourceScope: String) -> Bool
|
||||
|
||||
/// Deletes an account's credential and username under a named scope.
|
||||
///
|
||||
/// The scoped counterpart of `deleteCredential(forAccountID:)`: an import
|
||||
/// refused because this identity already tombstoned the account has to take
|
||||
/// the secret with it, and that secret is keyed by the scope being left
|
||||
/// rather than the one in force.
|
||||
func deleteCredential(forAccountID accountID: UUID, inScope scope: String)
|
||||
|
||||
// MARK: - Legacy adoption
|
||||
|
||||
/// Whether a pre-revamp credential for this account's instance exists on
|
||||
|
||||
@@ -210,6 +210,180 @@ final class AccountKeychainCredentials {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Identity migration
|
||||
|
||||
/// Moves an account's credential and username out of `sourceScope` and into
|
||||
/// the scope that is current now.
|
||||
///
|
||||
/// For accounts created while no Apple account was available: they live in
|
||||
/// the `local` namespace, and the credential keyed `local|<accountID>` has
|
||||
/// to follow the record when an identity claims it. Written the way a fresh
|
||||
/// sign-in under this identity would write it, with three protections:
|
||||
/// `addIfAbsent` so a credential already held under the current scope is
|
||||
/// never clobbered (decision 5), a read-back before anything is deleted, and
|
||||
/// the source left completely untouched on any failure so the next sweep can
|
||||
/// try again.
|
||||
///
|
||||
/// - Returns: `true` when `sourceScope` no longer owes this account
|
||||
/// anything — the credential moved, the target already held one and the
|
||||
/// source copy was redundant, or there was never one to move. `false`
|
||||
/// means the source is exactly as it was, and the caller must not prune
|
||||
/// the record that points at it.
|
||||
@discardableResult
|
||||
func migrateCredential(forAccountID accountID: UUID, fromScope sourceScope: String) -> Bool {
|
||||
guard let scope = currentScope() else { return false }
|
||||
// Nothing to move, and moving would delete what it had just written.
|
||||
guard sourceScope != scope else { return true }
|
||||
|
||||
let sourceKey = KeychainCredentialStore.credentialKey(scope: sourceScope, accountID: accountID)
|
||||
let targetKey = KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID)
|
||||
|
||||
guard let secret = store.value(forKey: sourceKey) else {
|
||||
// An account that was never signed in under the source scope. Its
|
||||
// username still moves — it is what the sign-in sheet prefills —
|
||||
// and there is nothing left for a retry to recover, so the record
|
||||
// may be pruned.
|
||||
migrateUsername(forAccountID: accountID, from: sourceScope, to: scope)
|
||||
loginsByScope[sourceScope]?.remove(accountID)
|
||||
return true
|
||||
}
|
||||
|
||||
// Decision 5: a credential already stored under the current identity was
|
||||
// written by a sign-in this Apple account actually made, so the copy
|
||||
// being migrated is redundant rather than a replacement.
|
||||
if store.value(forKey: targetKey) != nil {
|
||||
migrateUsername(forAccountID: accountID, from: sourceScope, to: scope)
|
||||
store.delete(key: sourceKey)
|
||||
loginsByScope[sourceScope]?.remove(accountID)
|
||||
loginsByScope[scope, default: []].insert(accountID)
|
||||
|
||||
LoggingService.shared.info(
|
||||
"Dropped a \(credentialNoun) made redundant by one already held under this identity",
|
||||
category: .keychain,
|
||||
details: "accountID=\(accountID), from=\(sourceScope)"
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
// A secret adopted from the pre-revamp unscoped item keeps its
|
||||
// provenance across the move: `adoptLegacyCredential` deliberately
|
||||
// writes it device-local so a login of unproven origin never reaches an
|
||||
// iCloud Keychain, and re-filing it as synchronizable here would undo
|
||||
// exactly that. Markers are keyed by instance, and only a migrated
|
||||
// account — whose ID *is* its instance's — is ever allowed to adopt, so
|
||||
// the account ID is the right instance to ask about.
|
||||
let wasAdopted = markers.hasAdopted(
|
||||
service: store.service,
|
||||
instanceID: accountID,
|
||||
scope: sourceScope
|
||||
)
|
||||
let syncEnabled = wasAdopted ? false : shouldSyncToiCloud
|
||||
|
||||
let status = store.addIfAbsent(secret, forKey: targetKey, synchronizable: syncEnabled)
|
||||
guard status == errSecSuccess else {
|
||||
LoggingService.shared.error(
|
||||
"Could not move a \(credentialNoun) into the current identity — leaving it where it is",
|
||||
category: .keychain,
|
||||
details: "accountID=\(accountID), from=\(sourceScope), status=\(store.describe(status))"
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// Verified before the source is deleted, the same way legacy adoption
|
||||
// is: a write that reports success and reads back as something else is
|
||||
// the one case where deleting the source would lose the credential
|
||||
// outright. Whatever is there is left alone and the source stands, so
|
||||
// the next sweep resolves it — as a redundant copy if a real credential
|
||||
// arrived in the gap.
|
||||
guard store.value(forKey: targetKey) == secret else {
|
||||
LoggingService.shared.error(
|
||||
"A moved \(credentialNoun) did not verify — the source is left for the next attempt",
|
||||
category: .keychain,
|
||||
details: "accountID=\(accountID), from=\(sourceScope)"
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
migrateUsername(forAccountID: accountID, from: sourceScope, to: scope)
|
||||
store.delete(key: sourceKey)
|
||||
|
||||
if wasAdopted {
|
||||
// The marker describes where the adopted copy now lives, or the
|
||||
// deletion that retires the unscoped item would no longer recognise
|
||||
// it as this account's to retire.
|
||||
markers.record(
|
||||
service: store.service,
|
||||
instanceID: accountID,
|
||||
accountID: accountID,
|
||||
scope: scope,
|
||||
secret: secret
|
||||
)
|
||||
markers.removeMarker(
|
||||
service: store.service,
|
||||
instanceID: accountID,
|
||||
accountID: accountID,
|
||||
scope: sourceScope
|
||||
)
|
||||
}
|
||||
|
||||
loginsByScope[sourceScope]?.remove(accountID)
|
||||
loginsByScope[scope, default: []].insert(accountID)
|
||||
|
||||
LoggingService.shared.info(
|
||||
"Moved a \(credentialNoun) into the current identity",
|
||||
category: .keychain,
|
||||
details: "accountID=\(accountID), from=\(sourceScope), iCloudSync=\(syncEnabled)"
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Moves the username item, which is a convenience rather than a secret.
|
||||
///
|
||||
/// Never fails the migration around it: a credential is what a session
|
||||
/// depends on, and a username that could not be copied costs a prefilled
|
||||
/// field. The source is deleted only once the target really holds one.
|
||||
private func migrateUsername(forAccountID accountID: UUID, from sourceScope: String, to scope: String) {
|
||||
let sourceKey = KeychainCredentialStore.usernameKey(scope: sourceScope, accountID: accountID)
|
||||
guard let username = store.value(forKey: sourceKey) else { return }
|
||||
|
||||
let targetKey = KeychainCredentialStore.usernameKey(scope: scope, accountID: accountID)
|
||||
if store.value(forKey: targetKey) == nil {
|
||||
store.addIfAbsent(username, forKey: targetKey, synchronizable: shouldSyncToiCloud)
|
||||
}
|
||||
|
||||
guard store.value(forKey: targetKey) != nil else { return }
|
||||
store.delete(key: sourceKey)
|
||||
}
|
||||
|
||||
/// Deletes an account's credential and username under a scope that is not
|
||||
/// necessarily the current one.
|
||||
///
|
||||
/// For the import sweep's tombstone rule: an account this identity has
|
||||
/// deliberately deleted is never imported, and the secret it was keyed to
|
||||
/// goes with the record rather than sitting under a namespace nothing will
|
||||
/// read again.
|
||||
func deleteCredential(forAccountID accountID: UUID, inScope scope: String) {
|
||||
store.delete(key: KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID))
|
||||
store.delete(key: KeychainCredentialStore.usernameKey(scope: scope, accountID: accountID))
|
||||
loginsByScope[scope]?.remove(accountID)
|
||||
|
||||
// Its marker described a copy that no longer exists. Keyed by instance,
|
||||
// and only a migrated account can have adopted anything, so the account
|
||||
// ID is the instance to name here.
|
||||
markers.removeMarker(
|
||||
service: store.service,
|
||||
instanceID: accountID,
|
||||
accountID: accountID,
|
||||
scope: scope
|
||||
)
|
||||
|
||||
LoggingService.shared.info(
|
||||
"Deleted a \(credentialNoun) left behind by a refused import",
|
||||
category: .keychain,
|
||||
details: "accountID=\(accountID), scope=\(scope)"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Username
|
||||
|
||||
func setUsername(_ username: String, for account: Account) {
|
||||
|
||||
@@ -309,6 +309,15 @@ extension InvidiousCredentialsManager: AccountCredentialsManager {
|
||||
accountCredentials.refreshLoginStatus(for: account)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func migrateCredential(forAccountID accountID: UUID, fromScope sourceScope: String) -> Bool {
|
||||
accountCredentials.migrateCredential(forAccountID: accountID, fromScope: sourceScope)
|
||||
}
|
||||
|
||||
func deleteCredential(forAccountID accountID: UUID, inScope scope: String) {
|
||||
accountCredentials.deleteCredential(forAccountID: accountID, inScope: scope)
|
||||
}
|
||||
|
||||
func hasAdoptableLegacyCredential(for account: Account) -> Bool {
|
||||
accountCredentials.hasAdoptableLegacyCredential(for: account)
|
||||
}
|
||||
|
||||
173
Yattee/Services/Credentials/LocalNamespaceImporter.swift
Normal file
173
Yattee/Services/Credentials/LocalNamespaceImporter.swift
Normal file
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// LocalNamespaceImporter.swift
|
||||
// Yattee
|
||||
//
|
||||
// Hands accounts created while signed out of iCloud to the identity that
|
||||
// signs in.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Imports the `local` account namespace into whichever identity is active.
|
||||
///
|
||||
/// Accounts added while no Apple account was available are stored under
|
||||
/// `accounts.local.*`, with their credentials keyed `local|<accountID>`. When an
|
||||
/// identity resolves, everything namespaced by it is reloaded — and without this
|
||||
/// the local data would simply stop being read, permanently.
|
||||
///
|
||||
/// A repeatable sweep rather than a one-shot migration, for two reasons. Signing
|
||||
/// out refills the local namespace, so the same work is owed again on the next
|
||||
/// sign-in; and any part of it that cannot be completed — a credential the
|
||||
/// Keychain refuses to copy — has to be retried rather than lost. Every step is
|
||||
/// therefore idempotent, and per item: an account is removed from the local
|
||||
/// namespace only once both its record and its secret have been dealt with.
|
||||
///
|
||||
/// Runs entirely synchronously on the main actor. The fences below are checked
|
||||
/// once, at entry, and nothing awaits afterwards — so an identity change cannot
|
||||
/// interleave with a sweep in progress.
|
||||
@MainActor
|
||||
final class LocalNamespaceImporter {
|
||||
private let accountsManager: AccountsManager
|
||||
private let identityScope: CredentialIdentityScope
|
||||
|
||||
/// The credential stores that could be holding an account's secret.
|
||||
///
|
||||
/// Usually the one its instance's type names. An account whose instance has
|
||||
/// not synced yet resolves to none, so every account-keyed store is asked
|
||||
/// instead — at most one of them holds anything, and the alternative is
|
||||
/// stranding a credential under a namespace nothing will ever read again.
|
||||
private let credentialStores: @MainActor (Account) -> [any AccountCredentialsManager]
|
||||
|
||||
init(
|
||||
accountsManager: AccountsManager,
|
||||
identityScope: CredentialIdentityScope,
|
||||
credentialStores: @escaping @MainActor (Account) -> [any AccountCredentialsManager]
|
||||
) {
|
||||
self.accountsManager = accountsManager
|
||||
self.identityScope = identityScope
|
||||
self.credentialStores = credentialStores
|
||||
}
|
||||
|
||||
/// Moves whatever the signed-out namespace still holds into the active
|
||||
/// identity.
|
||||
///
|
||||
/// - Returns: whether anything was taken over, so the caller can recompute
|
||||
/// what it derives from accounts and credentials.
|
||||
@discardableResult
|
||||
func sweepIfNeeded() -> Bool {
|
||||
let local = CredentialIdentityScope.localScope
|
||||
|
||||
// Decisions 9 and 10: an install with no Apple account — signed out, or
|
||||
// sideloaded with no iCloud entitlement — is permanently scoped `local`
|
||||
// and has nothing to import into, and the `unresolved` holding pen is
|
||||
// never a destination either.
|
||||
guard let scope = identityScope.current, scope != local else { return false }
|
||||
|
||||
// The same fence `save()` uses. The scope moves the instant the identity
|
||||
// does, while the reload that refills memory runs on a main-queue
|
||||
// observer afterwards; sweeping in that window would merge the local
|
||||
// namespace into a manager still describing the previous identity.
|
||||
guard accountsManager.loadedNamespace == scope else {
|
||||
LoggingService.shared.info(
|
||||
"Skipping the local-namespace import — the account namespace is still reloading",
|
||||
category: .general,
|
||||
details: "loaded=\(accountsManager.loadedNamespace), current=\(scope)"
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
let snapshot = accountsManager.localNamespaceSnapshot()
|
||||
guard snapshot.hasContent else { return false }
|
||||
|
||||
var pruneable: Set<UUID> = []
|
||||
var refused: Set<UUID> = []
|
||||
|
||||
for account in snapshot.accounts {
|
||||
switch accountsManager.adoptImportedAccount(account) {
|
||||
case .rejectedByTombstone:
|
||||
// Decision 5: the account was deliberately deleted under this
|
||||
// identity, so the local copy is not resurrected — and its
|
||||
// secret goes with it rather than outliving the record.
|
||||
refused.insert(account.id)
|
||||
for store in credentialStores(account) {
|
||||
store.deleteCredential(forAccountID: account.id, inScope: local)
|
||||
}
|
||||
pruneable.insert(account.id)
|
||||
|
||||
case .imported, .mergedIntoExisting:
|
||||
// Per-item atomicity: the local record is only dropped once its
|
||||
// credential is verifiably no longer owed by the local scope. A
|
||||
// failure leaves the record in *both* namespaces until the next
|
||||
// sweep — safe, because the collision rule then reads it as an
|
||||
// account the identity already holds and only fills in what is
|
||||
// missing.
|
||||
// Every store is asked even after one fails: they hold separate
|
||||
// Keychain services, and whichever can complete its move should,
|
||||
// rather than waiting on a retry it has nothing to do with.
|
||||
var moved = true
|
||||
for store in credentialStores(account) {
|
||||
let storeMoved = store.migrateCredential(forAccountID: account.id, fromScope: local)
|
||||
moved = moved && storeMoved
|
||||
}
|
||||
|
||||
if moved {
|
||||
pruneable.insert(account.id)
|
||||
} else {
|
||||
LoggingService.shared.warning(
|
||||
"An imported account's credential could not be moved — leaving it for the next sweep",
|
||||
category: .keychain,
|
||||
details: "accountID=\(account.id)"
|
||||
)
|
||||
}
|
||||
|
||||
case .deferred:
|
||||
// Only reachable if the namespace moved under a sweep, which
|
||||
// nothing here awaits long enough to allow. Nothing is pruned.
|
||||
LoggingService.shared.warning(
|
||||
"The account namespace moved during an import — leaving the record where it is",
|
||||
category: .general,
|
||||
details: "accountID=\(account.id)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Decision 4: the markers are copied, never moved.
|
||||
accountsManager.adoptImportedMigratedInstances(snapshot.migratedInstanceIDs)
|
||||
|
||||
var contextHandled = false
|
||||
if let carried = snapshot.storedContext {
|
||||
// Handled either way: adopted when this identity has never chosen a
|
||||
// context, dropped when it has. The local keys are cleared in both
|
||||
// cases, or every activation would offer the same stale selection.
|
||||
contextHandled = true
|
||||
|
||||
var context = carried.context
|
||||
// Decision 6: never carry a selection naming an account that was
|
||||
// just refused — or one that exists in neither namespace. The user's
|
||||
// own data is the only thing left to point at.
|
||||
if let accountID = context.accountID,
|
||||
refused.contains(accountID) || accountsManager.account(id: accountID) == nil {
|
||||
context = .local
|
||||
}
|
||||
|
||||
accountsManager.adoptImportedContext(
|
||||
StoredDataContext(context: context, changedAt: carried.changedAt)
|
||||
)
|
||||
}
|
||||
|
||||
accountsManager.pruneLocalNamespace(
|
||||
removingAccountIDs: pruneable,
|
||||
clearContext: contextHandled
|
||||
)
|
||||
|
||||
let didChange = !pruneable.isEmpty || contextHandled
|
||||
if didChange {
|
||||
LoggingService.shared.info(
|
||||
"Imported the signed-out account namespace into the active identity",
|
||||
category: .general,
|
||||
details: "imported=\(pruneable.count - refused.count), refused=\(refused.count)"
|
||||
)
|
||||
}
|
||||
return didChange
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,15 @@ extension PipedCredentialsManager: AccountCredentialsManager {
|
||||
accountCredentials.refreshLoginStatus(for: account)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func migrateCredential(forAccountID accountID: UUID, fromScope sourceScope: String) -> Bool {
|
||||
accountCredentials.migrateCredential(forAccountID: accountID, fromScope: sourceScope)
|
||||
}
|
||||
|
||||
func deleteCredential(forAccountID accountID: UUID, inScope scope: String) {
|
||||
accountCredentials.deleteCredential(forAccountID: accountID, inScope: scope)
|
||||
}
|
||||
|
||||
func hasAdoptableLegacyCredential(for account: Account) -> Bool {
|
||||
accountCredentials.hasAdoptableLegacyCredential(for: account)
|
||||
}
|
||||
|
||||
@@ -243,6 +243,8 @@ private final class DeletionOrderStore: AccountCredentialsManager {
|
||||
&& accountsManager.pendingDeletions.contains(accountID)
|
||||
}
|
||||
|
||||
func migrateCredential(forAccountID _: UUID, fromScope _: String) -> Bool { true }
|
||||
func deleteCredential(forAccountID _: UUID, inScope _: String) {}
|
||||
func resyncCredentialsWithCurrentSetting() {}
|
||||
func setUsername(_: String, for _: Account) {}
|
||||
func username(for _: Account) -> String? { nil }
|
||||
|
||||
564
YatteeTests/LocalNamespaceImportTests.swift
Normal file
564
YatteeTests/LocalNamespaceImportTests.swift
Normal file
@@ -0,0 +1,564 @@
|
||||
//
|
||||
// LocalNamespaceImportTests.swift
|
||||
// YatteeTests
|
||||
//
|
||||
// Importing the signed-out account namespace into the Apple account that
|
||||
// claims it.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Yattee
|
||||
|
||||
@Suite("Local namespace import")
|
||||
@MainActor
|
||||
struct LocalNamespaceImportTests {
|
||||
private var localScope: String { CredentialIdentityScope.localScope }
|
||||
|
||||
// MARK: - Basics
|
||||
|
||||
@Test("An account created while signed out follows the identity that signs in")
|
||||
func accountCredentialAndContextAreImported() async throws {
|
||||
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
|
||||
let instance = harness.addInstance()
|
||||
|
||||
let account = harness.accounts.addAccount(instanceID: instance.id, displayName: "Main")
|
||||
harness.credentials.setCredential("secret", for: account)
|
||||
harness.credentials.setUsername("btest1", for: account)
|
||||
harness.accounts.setContext(.account(account.id))
|
||||
defer { harness.cleanUp(account.id) }
|
||||
|
||||
try await harness.flip(to: "identity-A")
|
||||
|
||||
// The record landed in the identity's namespace, owed to the server.
|
||||
#expect(harness.accounts.account(id: account.id)?.displayName == "Main")
|
||||
#expect(harness.accounts.pendingUploads.contains(account.id))
|
||||
|
||||
// The credential came with it and reads under the new scope.
|
||||
#expect(harness.credentials.credential(for: account) == "secret")
|
||||
#expect(harness.credentials.username(for: account) == "btest1")
|
||||
#expect(harness.credentials.isLoggedIn(for: account))
|
||||
|
||||
// Move semantics: nothing is left behind in either store.
|
||||
#expect(harness.rawCredential(scope: localScope, accountID: account.id) == nil)
|
||||
#expect(harness.rawUsername(scope: localScope, accountID: account.id) == nil)
|
||||
#expect(harness.storedAccounts(in: localScope).isEmpty)
|
||||
|
||||
// The selection carried over, epoch-stamped and owed to the server.
|
||||
#expect(harness.accounts.currentContext == .account(account.id))
|
||||
#expect(harness.accounts.storedContext.isMigrated)
|
||||
#expect(harness.accounts.pendingContextUpload)
|
||||
|
||||
// Its instance's migration marker travelled with it.
|
||||
#expect(harness.accounts.migratedInstanceIDs.contains(instance.id))
|
||||
}
|
||||
|
||||
@Test("An account that was never signed in imports and prunes cleanly")
|
||||
func accountWithoutCredentialImports() async throws {
|
||||
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
|
||||
let account = harness.accounts.addAccount(instanceID: UUID(), displayName: "Never signed in")
|
||||
defer { harness.cleanUp(account.id) }
|
||||
|
||||
try await harness.flip(to: "identity-A")
|
||||
|
||||
#expect(harness.accounts.account(id: account.id)?.displayName == "Never signed in")
|
||||
#expect(harness.credentials.credential(for: account) == nil)
|
||||
#expect(harness.storedAccounts(in: localScope).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Collisions
|
||||
|
||||
@Test("An account the identity already holds keeps its record and only fills gaps")
|
||||
func collisionKeepsTheIdentityRecord() async throws {
|
||||
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
|
||||
let instanceID = UUID()
|
||||
|
||||
let newer = UUID()
|
||||
let older = UUID()
|
||||
let alreadySignedIn = UUID()
|
||||
defer { harness.cleanUp(newer, older, alreadySignedIn) }
|
||||
|
||||
// What the identity already knows, from its own devices.
|
||||
harness.seed("identity-A") { manager in
|
||||
for id in [newer, older, alreadySignedIn] {
|
||||
manager.applySyncedAccount(
|
||||
Account(
|
||||
id: id,
|
||||
instanceID: instanceID,
|
||||
displayName: "Identity",
|
||||
updatedAt: Date(timeIntervalSince1970: 1000)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
harness.seedCredential("identity-secret", accountID: alreadySignedIn, scope: "identity-A")
|
||||
|
||||
// What the device holds from being signed out.
|
||||
harness.accounts.applySyncedAccount(
|
||||
Account(id: newer, instanceID: instanceID, displayName: "Local newer", updatedAt: Date())
|
||||
)
|
||||
harness.accounts.applySyncedAccount(
|
||||
Account(
|
||||
id: older,
|
||||
instanceID: instanceID,
|
||||
displayName: "Local older",
|
||||
updatedAt: Date(timeIntervalSince1970: 500)
|
||||
)
|
||||
)
|
||||
harness.accounts.applySyncedAccount(
|
||||
Account(
|
||||
id: alreadySignedIn,
|
||||
instanceID: instanceID,
|
||||
displayName: "Identity",
|
||||
updatedAt: Date(timeIntervalSince1970: 1000)
|
||||
)
|
||||
)
|
||||
harness.seedCredential("local-secret", accountID: newer, scope: localScope)
|
||||
harness.seedCredential("local-secret", accountID: alreadySignedIn, scope: localScope)
|
||||
|
||||
try await harness.flip(to: "identity-A")
|
||||
|
||||
// A strictly newer label is merged; an older one is not.
|
||||
#expect(harness.accounts.account(id: newer)?.displayName == "Local newer")
|
||||
#expect(harness.accounts.account(id: older)?.displayName == "Identity")
|
||||
|
||||
// A credential is only ever filled in where the identity had none.
|
||||
#expect(harness.rawCredential(scope: "identity-A", accountID: newer) == "local-secret")
|
||||
#expect(harness.rawCredential(scope: "identity-A", accountID: alreadySignedIn) == "identity-secret")
|
||||
|
||||
// The redundant copies are gone and the local namespace is empty.
|
||||
#expect(harness.rawCredential(scope: localScope, accountID: newer) == nil)
|
||||
#expect(harness.rawCredential(scope: localScope, accountID: alreadySignedIn) == nil)
|
||||
#expect(harness.storedAccounts(in: localScope).isEmpty)
|
||||
}
|
||||
|
||||
@Test("An account this identity deleted is never resurrected from a local copy")
|
||||
func tombstoneRefusesTheImport() async throws {
|
||||
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
|
||||
let deleted = UUID()
|
||||
let instanceID = UUID()
|
||||
defer { harness.cleanUp(deleted) }
|
||||
|
||||
harness.seed("identity-A") { $0.applyRemoteTombstone(deleted) }
|
||||
|
||||
harness.accounts.applySyncedAccount(Account(id: deleted, instanceID: instanceID))
|
||||
harness.seedCredential("local-secret", accountID: deleted, scope: localScope)
|
||||
let survivor = harness.accounts.addAccount(instanceID: UUID())
|
||||
defer { harness.cleanUp(survivor.id) }
|
||||
|
||||
try await harness.flip(to: "identity-A")
|
||||
|
||||
#expect(harness.accounts.account(id: deleted) == nil)
|
||||
// Nothing is owed to the server for a record that was refused — and the
|
||||
// account that *was* imported still is.
|
||||
#expect(harness.accounts.pendingUploads == [survivor.id])
|
||||
#expect(harness.accounts.pendingDeletions.isEmpty)
|
||||
|
||||
// The secret went with the refused record, under both scopes.
|
||||
#expect(harness.rawCredential(scope: localScope, accountID: deleted) == nil)
|
||||
#expect(harness.rawCredential(scope: "identity-A", accountID: deleted) == nil)
|
||||
#expect(harness.storedAccounts(in: localScope).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Context
|
||||
|
||||
@Test("A context chosen while signed out is dropped when the identity has one")
|
||||
func identityContextWins() async throws {
|
||||
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
|
||||
let account = harness.accounts.addAccount(instanceID: UUID())
|
||||
defer { harness.cleanUp(account.id) }
|
||||
|
||||
harness.accounts.setContext(.account(account.id))
|
||||
harness.seed("identity-A") { $0.setContext(.local) }
|
||||
|
||||
try await harness.flip(to: "identity-A")
|
||||
|
||||
#expect(harness.accounts.currentContext == .local)
|
||||
// The local keys are cleared either way, or every activation would offer
|
||||
// the same stale selection again.
|
||||
#expect(harness.defaults.data(forKey: "accounts.local.context") == nil)
|
||||
#expect(!harness.defaults.bool(forKey: "accounts.local.hasContext"))
|
||||
}
|
||||
|
||||
@Test("A context naming an account the identity refused falls back to local data")
|
||||
func contextNamingARefusedAccountFallsBack() async throws {
|
||||
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
|
||||
let deleted = UUID()
|
||||
defer { harness.cleanUp(deleted) }
|
||||
|
||||
harness.seed("identity-A") { $0.applyRemoteTombstone(deleted) }
|
||||
|
||||
harness.accounts.applySyncedAccount(Account(id: deleted, instanceID: UUID()))
|
||||
harness.accounts.setContext(.account(deleted))
|
||||
|
||||
try await harness.flip(to: "identity-A")
|
||||
|
||||
#expect(harness.accounts.account(id: deleted) == nil)
|
||||
#expect(harness.accounts.currentContext == .local)
|
||||
#expect(harness.accounts.storedContext.isMigrated)
|
||||
}
|
||||
|
||||
// MARK: - Repetition
|
||||
|
||||
@Test("Sweeping again changes nothing and duplicates nothing")
|
||||
func sweepIsIdempotent() async throws {
|
||||
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
|
||||
let account = harness.accounts.addAccount(instanceID: UUID(), displayName: "Main")
|
||||
harness.credentials.setCredential("secret", for: account)
|
||||
harness.accounts.setContext(.account(account.id))
|
||||
defer { harness.cleanUp(account.id) }
|
||||
|
||||
try await harness.flip(to: "identity-A")
|
||||
|
||||
let accountsAfterFirst = harness.accounts.accounts
|
||||
let uploadsAfterFirst = harness.accounts.pendingUploads
|
||||
let contextAfterFirst = harness.accounts.storedContext
|
||||
|
||||
#expect(harness.importer.sweepIfNeeded() == false)
|
||||
#expect(harness.importer.sweepIfNeeded() == false)
|
||||
|
||||
#expect(harness.accounts.accounts == accountsAfterFirst)
|
||||
#expect(harness.accounts.pendingUploads == uploadsAfterFirst)
|
||||
#expect(harness.accounts.storedContext == contextAfterFirst)
|
||||
#expect(harness.credentials.credential(for: account) == "secret")
|
||||
}
|
||||
|
||||
@Test("A sign-out and sign-in cycle imports only what is new")
|
||||
func repeatedCyclesImportOnlyTheNewAccounts() async throws {
|
||||
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
|
||||
let first = harness.accounts.addAccount(instanceID: UUID(), displayName: "First")
|
||||
harness.credentials.setCredential("first-secret", for: first)
|
||||
defer { harness.cleanUp(first.id) }
|
||||
|
||||
try await harness.flip(to: "identity-A")
|
||||
#expect(harness.accounts.accounts.map(\.id) == [first.id])
|
||||
|
||||
// Signed out again: the local namespace is empty, and whatever is added
|
||||
// now lands there.
|
||||
try await harness.flip(to: CredentialIdentityScope.localScope)
|
||||
#expect(harness.accounts.accounts.isEmpty)
|
||||
|
||||
let second = harness.accounts.addAccount(instanceID: UUID(), displayName: "Second")
|
||||
harness.credentials.setCredential("second-secret", for: second)
|
||||
defer { harness.cleanUp(second.id) }
|
||||
|
||||
try await harness.flip(to: "identity-A")
|
||||
|
||||
#expect(harness.accounts.accounts.count == 2)
|
||||
#expect(harness.accounts.account(id: first.id)?.displayName == "First")
|
||||
#expect(harness.accounts.account(id: second.id)?.displayName == "Second")
|
||||
#expect(harness.credentials.credential(for: first) == "first-secret")
|
||||
#expect(harness.credentials.credential(for: second) == "second-secret")
|
||||
#expect(harness.storedAccounts(in: localScope).isEmpty)
|
||||
}
|
||||
|
||||
@Test("The signed-out migration markers stay behind, so nothing is synthesized twice")
|
||||
func localMigrationMarkersAreRetained() async throws {
|
||||
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
|
||||
let instance = harness.addInstance(usesAccountLogin: true)
|
||||
defer { harness.cleanUp(instance.id) }
|
||||
|
||||
harness.accounts.reconcileLegacyAccounts()
|
||||
let synthesized = try #require(harness.accounts.accounts(forInstance: instance.id).first)
|
||||
#expect(synthesized.id == instance.id)
|
||||
|
||||
try await harness.flip(to: "identity-A")
|
||||
#expect(harness.accounts.account(id: instance.id) != nil)
|
||||
#expect(harness.accounts.migratedInstanceIDs.contains(instance.id))
|
||||
|
||||
// Signing out again must not re-synthesize the account that just left:
|
||||
// `usesAccountLogin` is still true on the instance forever, and only the
|
||||
// retained marker stops the migration running a second time.
|
||||
try await harness.flip(to: CredentialIdentityScope.localScope)
|
||||
#expect(harness.accounts.accounts.isEmpty)
|
||||
|
||||
harness.accounts.reconcileLegacyAccounts()
|
||||
#expect(harness.accounts.accounts.isEmpty)
|
||||
#expect(harness.accounts.migratedInstanceIDs.contains(instance.id))
|
||||
}
|
||||
|
||||
@Test("A deletion made while signed out is never published into the Apple account")
|
||||
func localDeletionFactsStayLocal() async throws {
|
||||
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
|
||||
let doomed = harness.accounts.addAccount(instanceID: UUID())
|
||||
harness.accounts.removeAccount(doomed.id)
|
||||
|
||||
let kept = harness.accounts.addAccount(instanceID: UUID())
|
||||
defer { harness.cleanUp(doomed.id, kept.id) }
|
||||
|
||||
try await harness.flip(to: "identity-A")
|
||||
|
||||
#expect(harness.accounts.accounts.map(\.id) == [kept.id])
|
||||
#expect(harness.accounts.deletedAccountIDs.isEmpty)
|
||||
#expect(harness.accounts.pendingDeletions.isEmpty)
|
||||
// ...and the local namespace keeps them, so signing out still works.
|
||||
#expect(
|
||||
harness.defaults.array(forKey: "accounts.local.deletedAccounts") as? [String]
|
||||
== [doomed.id.uuidString]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Fences
|
||||
|
||||
@Test("A sweep is refused while the account namespace is still reloading")
|
||||
func sweepIsFencedOnTheLoadedNamespace() {
|
||||
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
|
||||
let account = harness.accounts.addAccount(instanceID: UUID())
|
||||
harness.credentials.setCredential("secret", for: account)
|
||||
defer { harness.cleanUp(account.id) }
|
||||
|
||||
// The scope moves before the observer that reloads the manager runs.
|
||||
harness.scope.setFixedScope("identity-A", announcing: false)
|
||||
|
||||
#expect(harness.importer.sweepIfNeeded() == false)
|
||||
#expect(harness.storedAccounts(in: localScope).map(\.id) == [account.id])
|
||||
#expect(harness.defaults.data(forKey: "accounts.identity-A.accounts") == nil)
|
||||
#expect(harness.rawCredential(scope: localScope, accountID: account.id) == "secret")
|
||||
}
|
||||
|
||||
@Test("An unresolved scope is never a destination, and the identity sweeps once")
|
||||
func unresolvedScopeIsNotADestination() async throws {
|
||||
let harness = Harness(startingScope: CredentialIdentityScope.localScope)
|
||||
let account = harness.accounts.addAccount(instanceID: UUID(), displayName: "Main")
|
||||
harness.credentials.setCredential("secret", for: account)
|
||||
defer { harness.cleanUp(account.id) }
|
||||
|
||||
// iCloud is signed in but the user record has not been fetched yet.
|
||||
try await harness.flip(to: nil)
|
||||
#expect(harness.sweeps == 0)
|
||||
#expect(harness.storedAccounts(in: localScope).map(\.id) == [account.id])
|
||||
#expect(harness.defaults.data(forKey: "accounts.unresolved.accounts") == nil)
|
||||
|
||||
try await harness.flip(to: "identity-A")
|
||||
#expect(harness.sweeps == 1)
|
||||
#expect(harness.accounts.account(id: account.id) != nil)
|
||||
|
||||
// Any later activation of the same identity finds nothing left to do.
|
||||
harness.accounts.reloadForCurrentIdentity()
|
||||
#expect(harness.sweeps == 1)
|
||||
#expect(harness.accounts.account(id: account.id) != nil)
|
||||
}
|
||||
|
||||
@Test("Whichever identity activates claims what was made while signed out")
|
||||
func identitySwitchClaimsTheLocalNamespace() async throws {
|
||||
let harness = Harness(startingScope: "identity-A")
|
||||
let held = harness.accounts.addAccount(instanceID: UUID(), displayName: "A's own")
|
||||
defer { harness.cleanUp(held.id) }
|
||||
|
||||
// Made while the device was signed out, before B signed in.
|
||||
let madeSignedOut = UUID()
|
||||
defer { harness.cleanUp(madeSignedOut) }
|
||||
harness.seed(CredentialIdentityScope.localScope) { manager in
|
||||
manager.applySyncedAccount(
|
||||
Account(id: madeSignedOut, instanceID: UUID(), displayName: "Made signed out")
|
||||
)
|
||||
}
|
||||
harness.seedCredential("local-secret", accountID: madeSignedOut, scope: localScope)
|
||||
|
||||
try await harness.flip(to: "identity-B")
|
||||
|
||||
#expect(harness.accounts.account(id: madeSignedOut)?.displayName == "Made signed out")
|
||||
#expect(harness.accounts.pendingUploads.contains(madeSignedOut))
|
||||
#expect(harness.rawCredential(scope: "identity-B", accountID: madeSignedOut) == "local-secret")
|
||||
#expect(harness.storedAccounts(in: localScope).isEmpty)
|
||||
|
||||
// A's own namespace is untouched by B claiming the local one.
|
||||
#expect(harness.storedAccounts(in: "identity-A").map(\.id) == [held.id])
|
||||
#expect(harness.accounts.account(id: held.id) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test Support
|
||||
|
||||
/// A full local-namespace setup: storage, a Keychain service, an identity scope
|
||||
/// that can be moved, and the importer wired the way the app wires it.
|
||||
@MainActor
|
||||
private final class Harness {
|
||||
let defaults: UserDefaults
|
||||
let scope: CredentialIdentityScope
|
||||
let instances: InstancesManager
|
||||
let accounts: AccountsManager
|
||||
let credentials: TestCredentialsManager
|
||||
let importer: LocalNamespaceImporter
|
||||
|
||||
/// Sweeps that actually took something over.
|
||||
private(set) var sweeps = 0
|
||||
|
||||
private let store: KeychainCredentialStore
|
||||
|
||||
init(startingScope: String?) {
|
||||
let defaults = UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!
|
||||
// Unique per run so a crashed run's leftovers can never be read back.
|
||||
let service = "com.yattee.tests.\(UUID().uuidString)"
|
||||
|
||||
self.defaults = defaults
|
||||
self.store = KeychainCredentialStore(service: service)
|
||||
self.instances = makeTestInstancesManager()
|
||||
|
||||
let scope = CredentialIdentityScope(fixedScope: startingScope)
|
||||
self.scope = scope
|
||||
|
||||
let accounts = AccountsManager(
|
||||
identityScope: scope,
|
||||
instancesManager: instances,
|
||||
defaults: defaults
|
||||
)
|
||||
self.accounts = accounts
|
||||
|
||||
let credentials = TestCredentialsManager(
|
||||
service: service,
|
||||
identityScope: scope,
|
||||
markers: CredentialAdoptionMarkerStore(defaults: defaults)
|
||||
)
|
||||
self.credentials = credentials
|
||||
|
||||
self.importer = LocalNamespaceImporter(
|
||||
accountsManager: accounts,
|
||||
identityScope: scope,
|
||||
credentialStores: { _ in [credentials] }
|
||||
)
|
||||
|
||||
accounts.onNamespaceActivated = { [weak self] in
|
||||
guard let self, self.importer.sweepIfNeeded() else { return }
|
||||
self.sweeps += 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Driving
|
||||
|
||||
/// Moves the identity scope the way a sign-in does, and lets the main-queue
|
||||
/// observers that reload and sweep run.
|
||||
func flip(to newScope: String?) async throws {
|
||||
scope.setFixedScope(newScope)
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addInstance(usesAccountLogin: Bool = false) -> Instance {
|
||||
let instance = Instance(
|
||||
type: .invidious,
|
||||
url: URL(string: "https://import-\(UUID().uuidString).example.com")!,
|
||||
usesAccountLogin: usesAccountLogin
|
||||
)
|
||||
instances.add(instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
/// Writes into a namespace other than the one under test, the way another
|
||||
/// device's sync would have filled it.
|
||||
func seed(_ namespace: String, _ body: (AccountsManager) -> Void) {
|
||||
body(
|
||||
AccountsManager(
|
||||
identityScope: CredentialIdentityScope(fixedScope: namespace),
|
||||
instancesManager: instances,
|
||||
defaults: defaults
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func seedCredential(_ value: String, accountID: UUID, scope: String) {
|
||||
store.set(
|
||||
value,
|
||||
forKey: KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID),
|
||||
synchronizable: false
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Reading storage directly
|
||||
|
||||
func storedAccounts(in namespace: String) -> [Account] {
|
||||
guard let data = defaults.data(forKey: "accounts.\(namespace).accounts"),
|
||||
let decoded = try? JSONDecoder().decode([Account].self, from: data)
|
||||
else { return [] }
|
||||
return decoded
|
||||
}
|
||||
|
||||
func rawCredential(scope: String, accountID: UUID) -> String? {
|
||||
store.value(forKey: KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID))
|
||||
}
|
||||
|
||||
func rawUsername(scope: String, accountID: UUID) -> String? {
|
||||
store.value(forKey: KeychainCredentialStore.usernameKey(scope: scope, accountID: accountID))
|
||||
}
|
||||
|
||||
/// The Keychain outlives the test process, so every key a test could have
|
||||
/// written is removed by hand.
|
||||
func cleanUp(_ accountIDs: UUID...) {
|
||||
for accountID in accountIDs {
|
||||
for scope in [CredentialIdentityScope.localScope, "identity-A", "identity-B", "unresolved"] {
|
||||
store.delete(key: KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID))
|
||||
store.delete(key: KeychainCredentialStore.usernameKey(scope: scope, accountID: accountID))
|
||||
}
|
||||
store.delete(key: KeychainCredentialStore.legacyKey(instanceID: accountID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An account-keyed credential store over a Keychain service of its own.
|
||||
///
|
||||
/// The shipping managers hard-code their service names, which under a test host
|
||||
/// are the ones the installed app uses. This wraps the same engine they wrap, so
|
||||
/// the protocol the importer talks to is exercised for real.
|
||||
@MainActor
|
||||
@Observable
|
||||
private final class TestCredentialsManager: AccountCredentialsManager {
|
||||
private let credentials: AccountKeychainCredentials
|
||||
|
||||
init(service: String, identityScope: CredentialIdentityScope, markers: CredentialAdoptionMarkerStore) {
|
||||
credentials = AccountKeychainCredentials(
|
||||
service: service,
|
||||
credentialNoun: "credential",
|
||||
identityScope: identityScope,
|
||||
markers: markers
|
||||
)
|
||||
}
|
||||
|
||||
var loggedInAccountIDs: Set<UUID> { credentials.loggedInAccountIDs }
|
||||
var isScopeResolved: Bool { credentials.isScopeResolved }
|
||||
|
||||
func setCredential(_ credential: String, for account: Account) {
|
||||
credentials.setCredential(credential, for: account)
|
||||
}
|
||||
|
||||
func credential(for account: Account) -> String? { credentials.credential(for: account) }
|
||||
|
||||
func deleteCredential(for account: Account) { credentials.deleteCredential(for: account) }
|
||||
|
||||
func deleteCredential(forAccountID accountID: UUID) {
|
||||
credentials.deleteCredential(forAccountID: accountID)
|
||||
}
|
||||
|
||||
func resyncCredentialsWithCurrentSetting() { credentials.resyncCredentialsWithCurrentSetting() }
|
||||
|
||||
func setUsername(_ username: String, for account: Account) {
|
||||
credentials.setUsername(username, for: account)
|
||||
}
|
||||
|
||||
func username(for account: Account) -> String? { credentials.username(for: account) }
|
||||
|
||||
func isLoggedIn(for account: Account) -> Bool { credentials.isLoggedIn(for: account) }
|
||||
|
||||
func refreshLoginStatus(for account: Account) { credentials.refreshLoginStatus(for: account) }
|
||||
|
||||
@discardableResult
|
||||
func migrateCredential(forAccountID accountID: UUID, fromScope sourceScope: String) -> Bool {
|
||||
credentials.migrateCredential(forAccountID: accountID, fromScope: sourceScope)
|
||||
}
|
||||
|
||||
func deleteCredential(forAccountID accountID: UUID, inScope scope: String) {
|
||||
credentials.deleteCredential(forAccountID: accountID, inScope: scope)
|
||||
}
|
||||
|
||||
func hasAdoptableLegacyCredential(for account: Account) -> Bool {
|
||||
credentials.hasAdoptableLegacyCredential(for: account)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func adoptLegacyCredential(for account: Account) -> Bool {
|
||||
credentials.adoptLegacyCredential(for: account)
|
||||
}
|
||||
|
||||
func deleteLegacyCredentialIfOwned(for account: Account) {
|
||||
credentials.deleteLegacyCredentialIfOwned(for: account)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user