mirror of
https://github.com/yattee/yattee.git
synced 2026-11-01 01:17:20 +00:00
Stop a fresh login from synthesizing a phantom migrated account
Signing in wrote `usesAccountLogin`, which syncs through key-value storage independently of the account records. A device receiving that flag before the account synthesized a migrated account for a login that was never legacy — and the marker arriving later deliberately retracts nothing, so the credential-less duplicate was permanent and uploaded. The flag is pre-revamp state that migration only reads; accounts describe logins now. `needsCredentials` reads the account list for any instance that has one, so the badge it drives is unaffected. A tombstone now dominates contexts as well as accounts: an offline device can select an account another device deleted and publish that choice with a newer timestamp, which would have left every device pointing at an account that is never coming back. Cached private feeds survived an Apple-account switch two ways. A scope this device has seen before resolves while `CredentialIdentityScope` is still being constructed, so the change notification fires before any observer exists — startup now compares the persisted cache owner. And a purge asked for before the disk cache is read was undone by the load, which now carries it. The need to re-bootstrap after dropping records for want of a resolved identity is persisted. The engine may already have moved its token past those records, so a launch that lost the in-memory flag would neither replay nor bootstrap them.
This commit is contained in:
@@ -612,8 +612,32 @@ struct AccountSyncTests {
|
||||
#expect(identityA.accounts.account(id: account.id) != nil)
|
||||
}
|
||||
|
||||
@Test("A context naming a deleted account is never adopted")
|
||||
func tombstoneDominatesASyncedContext() async {
|
||||
let harness = Self.makeHarness()
|
||||
let account = harness.accounts.addAccount(instanceID: UUID())
|
||||
harness.accounts.removeAccount(account.id)
|
||||
|
||||
// An offline device can select an account another device has already
|
||||
// deleted, and publish that choice with a newer timestamp.
|
||||
let stale = StoredDataContext(
|
||||
context: .account(account.id),
|
||||
changedAt: Date().addingTimeInterval(3_600)
|
||||
)
|
||||
harness.coordinator.stageFetched(
|
||||
records: [harness.mapper.toCKRecord(dataContext: stale)],
|
||||
deletions: []
|
||||
)
|
||||
await harness.coordinator.activate()
|
||||
|
||||
#expect(harness.accounts.currentContext == .local)
|
||||
// The choice's own timestamp is kept, so a later real selection still
|
||||
// outranks this repair on every device.
|
||||
#expect(harness.accounts.storedContext.changedAt == stale.changedAt)
|
||||
}
|
||||
|
||||
@Test("Records fetched before the identity resolves are recovered by a bootstrap")
|
||||
func unresolvedIdentityForcesAnotherBootstrap() {
|
||||
func unresolvedIdentityForcesAnotherBootstrap() async {
|
||||
let defaults = Self.makeDefaults()
|
||||
let store = AccountSyncStore(defaults: defaults)
|
||||
store.markBootstrapCompleted(in: "identity-A")
|
||||
@@ -640,14 +664,27 @@ struct AccountSyncTests {
|
||||
#expect(store.stagedChanges(in: "unresolved").isEmpty)
|
||||
|
||||
// The engine's token has moved past those records, so the identity that
|
||||
// resolves has to fetch the zone in full again to see them.
|
||||
// resolves has to fetch the zone in full again to see them. The
|
||||
// requirement is persisted, not held in memory: the token may already
|
||||
// have advanced, and a launch that lost it would neither replay nor
|
||||
// bootstrap.
|
||||
#expect(store.hasPendingIdentityRecovery)
|
||||
|
||||
identity.value = "identity-A"
|
||||
coordinator.identityDidChange()
|
||||
let relaunched = AccountSyncCoordinator(
|
||||
accountsManager: accounts,
|
||||
namespaceProvider: { identity.value },
|
||||
store: AccountSyncStore(defaults: defaults),
|
||||
isCategoryEnabled: { true }
|
||||
)
|
||||
await relaunched.activate()
|
||||
|
||||
#expect(!store.isBootstrapCompleted(in: "identity-A"))
|
||||
#expect(!store.hasPendingIdentityRecovery)
|
||||
}
|
||||
|
||||
@Test("A switch of Apple account never claims records dropped while unresolved")
|
||||
func switchedIdentityDoesNotClaimDroppedRecords() {
|
||||
func switchedIdentityDoesNotClaimDroppedRecords() async {
|
||||
let defaults = Self.makeDefaults()
|
||||
let store = AccountSyncStore(defaults: defaults)
|
||||
store.markBootstrapCompleted(in: "identity-A")
|
||||
@@ -672,10 +709,8 @@ struct AccountSyncTests {
|
||||
records: [AccountSyncTests.makeMapper().toCKRecord(account: Account(instanceID: UUID()))],
|
||||
deletions: []
|
||||
)
|
||||
coordinator.identityDidChange()
|
||||
|
||||
identity.value = "identity-B"
|
||||
coordinator.identityDidChange()
|
||||
await coordinator.activate()
|
||||
|
||||
// Neither identity is handed the other's records; both recover their
|
||||
// own from their own zone instead.
|
||||
|
||||
Reference in New Issue
Block a user