Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Vault/Sources/VaultFeed/Storage/VaultDataModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,11 @@ extension VaultDataModel {
// If killphrase deletion occurred, sync OTP autofill store to remove deleted items
if didDeleteKillphraseItems {
try? await syncAllToOTPAutofillStore()
// Refresh the payload hash before notifying, otherwise
// auto-backup compares its last-backup hash against the
// stale value and skips — leaving the deleted items
// recoverable from the newest backup.
await updateCurrentPayloadHash()
// Notify downstream observers (auto-backup, widget reload) that
// the item set changed silently from a killphrase match.
onDataChanged?()
Expand Down Expand Up @@ -385,6 +390,8 @@ extension VaultDataModel {
searchableLevel: item.searchableLevel,
showInQuickType: item.showInQuickType,
)
await updateCurrentPayloadHash()
onDataChanged?()
}

public func update(itemID id: Identifier<VaultItem>, data: VaultItem.Write) async throws {
Expand Down Expand Up @@ -444,6 +451,7 @@ extension VaultDataModel: VaultStoreHOTPIncrementer {
public func incrementCounter(id: Identifier<VaultItem>) async throws {
try await vaultStore.incrementCounter(id: id)
await reloadItems()
await updateCurrentPayloadHash()
onDataChanged?()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,38 @@ struct AutoBackupServiceImplTests {
#expect(provider.writeCallCount == 0)
}

@Test @LeakTracked
func triggerBackupIfNeeded_runsAfterKillphraseDeletionChangesHash() async throws {
let provider = BackupStorageProviderStub(id: "test")
let store = VaultStoreStub()
let killphraseDeleter = VaultStoreKillphraseDeleterMock()
let dataModel = anyVaultDataModel(vaultStore: store, vaultKillphraseDeleter: killphraseDeleter)
try await dataModel.store(backupPassword: anyBackupPassword())
await dataModel.setup()
let sut = try makeSUT(providers: [provider], dataModel: dataModel)
await Task.yield()
await sut.setEnabled(true)
await sut.selectProvider(id: "test")
// Take a backup so lastBackupHash matches the current payload hash.
await sut.forceBackup()
#expect(provider.writeCallCount == 1)

// Killphrase fires: the store's contents change out from under
// the model, and the payload hash must be refreshed on that path
// or this trigger compares the stale hash and skips — leaving the
// killed items recoverable from the newest backup.
killphraseDeleter.deleteItemsHandler = { _, _ in true }
dataModel.itemsSearchQuery = "kill phrase"
store.exportVaultHandler = { userDescription in
.init(userDescription: userDescription, items: [uniqueVaultItem()], tags: [])
}
await dataModel.reloadItems()

await sut.triggerBackupIfNeeded()

#expect(provider.writeCallCount == 2)
}

// MARK: - Force Backup

@Test @LeakTracked
Expand Down
105 changes: 103 additions & 2 deletions Vault/Tests/VaultFeedTests/Storage/VaultDataModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,100 @@ final class VaultDataModelTests {
#expect(vaultOtpAutofillStore.syncAllCallCount == 1)
}

@Test
func reloadItems_refreshesPayloadHashWhenKillphraseDeletesItems() async throws {
let store = VaultStoreStub()
let killphraseDeleter = VaultStoreKillphraseDeleterMock()
let keyStore = KillphraseKeyStoreMock()
keyStore.loadOrCreateHandler = {
(try? KeyData<32>(data: Data(repeating: 0xAA, count: 32))) ?? .zero()
}
let sut = makeSUT(
vaultStore: store,
vaultKillphraseDeleter: killphraseDeleter,
killphraseKeyStore: keyStore,
)
await sut.setup()
let hashBeforeDeletion = try #require(sut.currentPayloadHash)
sut.itemsSearchQuery = "hello world"
killphraseDeleter.deleteItemsHandler = { _, _ in true }
// The store's contents change out from under the model when the
// killphrase fires; auto-backup relies on the refreshed hash to
// notice, otherwise the deleted items persist in the newest backup.
store.exportVaultHandler = { userDescription in
VaultApplicationPayload(
userDescription: userDescription,
items: [uniqueVaultItem()],
tags: [],
)
}

await sut.reloadItems()

#expect(sut.currentPayloadHash != nil)
#expect(sut.currentPayloadHash != hashBeforeDeletion)
}

@Test
func reloadItems_doesNotRefreshPayloadHashWhenNoKillphraseDeletionOccurs() async throws {
let store = VaultStoreStub()
let sut = makeSUT(vaultStore: store)
await sut.setup()
let exportsAfterSetup = store.exportVaultCallCount

await sut.reloadItems()

// Plain reloads (every search keystroke) must not trigger a full
// vault export just to recompute the hash.
#expect(store.exportVaultCallCount == exportsAfterSetup)
}

@Test
func insert_refreshesPayloadHashAndNotifiesDataChanged() async throws {
let store = VaultStoreStub()
let sut = makeSUT(vaultStore: store)
await sut.setup()
let hashBeforeInsert = try #require(sut.currentPayloadHash)
store.exportVaultHandler = { userDescription in
VaultApplicationPayload(
userDescription: userDescription,
items: [uniqueVaultItem()],
tags: [],
)
}

try await confirmation("Data change notified", expectedCount: 1) { confirmChange in
sut.onDataChanged = {
confirmChange()
}

try await sut.insert(item: uniqueVaultItem().makeWritable())
}

#expect(sut.currentPayloadHash != nil)
#expect(sut.currentPayloadHash != hashBeforeInsert)
}

@Test
func incrementCounter_refreshesPayloadHash() async throws {
let store = VaultStoreStub()
let sut = makeSUT(vaultStore: store)
await sut.setup()
let hashBeforeIncrement = try #require(sut.currentPayloadHash)
store.exportVaultHandler = { userDescription in
VaultApplicationPayload(
userDescription: userDescription,
items: [uniqueVaultItem()],
tags: [],
)
}

try await sut.incrementCounter(id: .new())

#expect(sut.currentPayloadHash != nil)
#expect(sut.currentPayloadHash != hashBeforeIncrement)
}

@Test
func insert_createsItemInStoreAndReloads() async throws {
let store = VaultStoreStub()
Expand All @@ -500,7 +594,11 @@ final class VaultDataModelTests {

try await sut.insert(item: item)

#expect(store.calledMethods == [.insert, .retrieve])
#expect(store.calledMethods == [
.insert,
.retrieve, // reload items
.export, // export for payload hash
])
}

@Test
Expand Down Expand Up @@ -789,7 +887,10 @@ final class VaultDataModelTests {
}

#expect(store.incrementCounterCallCount == 1)
#expect(store.calledMethods == [.retrieve])
#expect(store.calledMethods == [
.retrieve, // reload items
.export, // export for payload hash
])
}

@Test
Expand Down