Updated on 2026-08-14
This commit is contained in:
parent
8b49dc2f7f
commit
54387cd1ef
3 changed files with 52 additions and 6 deletions
|
|
@ -102,11 +102,12 @@ internal class DefaultAddressBookRepository(
|
||||||
writeMutex.withLock {
|
writeMutex.withLock {
|
||||||
val userWallet = findUserWallet(contact.walletId.stringValue)
|
val userWallet = findUserWallet(contact.walletId.stringValue)
|
||||||
?: return@withLock AddressBookSyncError.Unknown.left()
|
?: return@withLock AddressBookSyncError.Unknown.left()
|
||||||
val current = currentContacts(contact.walletId, userWallet)
|
currentContacts(contact.walletId, userWallet).flatMap { current ->
|
||||||
val merged = current.filterNot { it.id == contact.id } + contact
|
val merged = current.filterNot { it.id == contact.id } + contact
|
||||||
persist(userWallet, AddressBook(contacts = merged))
|
persist(userWallet, AddressBook(contacts = merged))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit> =
|
override suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit> =
|
||||||
withContext(dispatchers.default) {
|
withContext(dispatchers.default) {
|
||||||
|
|
@ -183,9 +184,28 @@ internal class DefaultAddressBookRepository(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun currentContacts(userWalletId: UserWalletId, userWallet: UserWallet): List<Contact> {
|
/**
|
||||||
val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList()
|
* The contacts currently stored for [userWalletId], as the base a write is merged onto.
|
||||||
return decryptContacts(blob, userWallet)
|
*
|
||||||
|
* A missing blob means no book exists yet → an empty base, so the first contact legitimately creates it.
|
||||||
|
* But a blob that fails to decrypt must surface as [AddressBookSyncError.DecryptionFailed] rather than an empty
|
||||||
|
* list: treating a broken book as empty would let a merged write overwrite the (non-empty) backend copy with a
|
||||||
|
* book built from a single new contact, wiping every existing one.
|
||||||
|
*/
|
||||||
|
private suspend fun currentContacts(
|
||||||
|
userWalletId: UserWalletId,
|
||||||
|
userWallet: UserWallet,
|
||||||
|
): Either<AddressBookSyncError, List<Contact>> {
|
||||||
|
val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList<Contact>().right()
|
||||||
|
return cipher.decrypt(blob, userWallet)
|
||||||
|
.map { it.contacts }
|
||||||
|
.mapLeft { error ->
|
||||||
|
logger.e(
|
||||||
|
"Refusing to overwrite address book for wallet $userWalletId: it is stored but decrypt " +
|
||||||
|
"failed with $error",
|
||||||
|
)
|
||||||
|
AddressBookSyncError.DecryptionFailed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,25 @@ internal class DefaultAddressBookRepositoryTest {
|
||||||
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
|
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN stored book that fails to decrypt WHEN saveContact THEN returns DecryptionFailed and pushes nothing`() =
|
||||||
|
runTest {
|
||||||
|
// Arrange
|
||||||
|
val storedBlob = createBlob()
|
||||||
|
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||||
|
every { cipher.decrypt(storedBlob, userWallet) } returns AddressBookCryptoError.DecryptionFailed.left()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = repository.saveContact(createContact(id = "c2", name = "Bob"))
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result).isEqualTo(AddressBookSyncError.DecryptionFailed.left())
|
||||||
|
// The broken book is left untouched: nothing is encrypted, pushed, or stored.
|
||||||
|
coVerify(exactly = 0) { addressBookApi.updateAddressBook(any(), any(), any()) }
|
||||||
|
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
|
||||||
|
coVerify(exactly = 0) { eTagsStore.store(any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN existing contact id WHEN saveContact THEN replaces it`() = runTest {
|
fun `GIVEN existing contact id WHEN saveContact THEN replaces it`() = runTest {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,13 @@ sealed interface AddressBookSyncError {
|
||||||
/** No network or the request could not be completed. */
|
/** No network or the request could not be completed. */
|
||||||
data object Network : AddressBookSyncError
|
data object Network : AddressBookSyncError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wallet already has a stored book that could not be decrypted. The write is refused so the broken
|
||||||
|
* (but non-empty) backend book is not overwritten by a fresh one built on the device — see
|
||||||
|
* [com.tangem.domain.addressbook.repository.AddressBookRepository.saveContact].
|
||||||
|
*/
|
||||||
|
data object DecryptionFailed : AddressBookSyncError
|
||||||
|
|
||||||
/** Any other unexpected failure (encryption, missing data, unmapped HTTP code). */
|
/** Any other unexpected failure (encryption, missing data, unmapped HTTP code). */
|
||||||
data object Unknown : AddressBookSyncError
|
data object Unknown : AddressBookSyncError
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue