Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-17 10:59:26 +02:00
parent 98d0d9c75e
commit b1c19eff9e
21 changed files with 538 additions and 52 deletions

View file

@ -95,6 +95,23 @@ internal class DefaultAddressBookRepository(
decryptContacts(blob, userWallet).find { it.name.value == name }
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun isAddressBookCompatible(userWalletId: UserWalletId?): Flow<Boolean> {
val source = if (userWalletId != null) {
blobStore.getBlob(userWalletId).map { blob -> blob?.isVersionCompatible != false }
} else {
userWalletsListRepository.userWallets
.filterNotNull()
.flatMapLatest { wallets ->
val ids = wallets.mapTo(mutableSetOf()) { it.walletId }
// getBlobs returns only wallets that already have a stored book; wallets without one are
// absent here and count as compatible (a fresh book uses the supported version).
blobStore.getBlobs(ids).map { blobs -> blobs.all { it.isVersionCompatible } }
}
}
return source.distinctUntilChanged().flowOn(dispatchers.default)
}
override suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit> =
withContext(dispatchers.default) {
writeMutex.withLock {
@ -112,6 +129,7 @@ internal class DefaultAddressBookRepository(
writeMutex.withLock {
userWalletsListRepository.userWalletsSync().forEach { userWallet ->
val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach
if (!blob.isVersionCompatible) return@forEach
val addressBook = cipher.decrypt(blob, userWallet).getOrNull() ?: return@forEach
if (addressBook.contacts.none { it.id == id }) return@forEach
@ -171,6 +189,13 @@ internal class DefaultAddressBookRepository(
}
private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List<Contact> {
if (!blob.isVersionCompatible) {
logger.e(
"Skipping address book for wallet ${blob.walletId}: version ${blob.version} is newer than " +
"supported ${AddressBookBlob.CURRENT_VERSION}",
)
return emptyList()
}
return cipher.decrypt(blob, userWallet).fold(
ifLeft = { error ->
// The cipher already logged the low-level cause; this ties the failure to the read path so QA
@ -195,6 +220,13 @@ internal class DefaultAddressBookRepository(
userWallet: UserWallet,
): Either<AddressBookSyncError, List<Contact>> {
val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList<Contact>().right()
if (!blob.isVersionCompatible) {
logger.e(
"Refusing to overwrite address book for wallet $userWalletId: stored version ${blob.version} is " +
"newer than supported ${AddressBookBlob.CURRENT_VERSION} — a write would downgrade it",
)
return AddressBookSyncError.VersionMismatch.left()
}
return cipher.decrypt(blob, userWallet)
.map { it.contacts }
.mapLeft { error ->

View file

@ -14,22 +14,13 @@ import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.domain.addressbook.crypto.AddressBookCipher
import com.tangem.domain.addressbook.error.AddressBookCryptoError
import com.tangem.domain.addressbook.error.AddressBookSyncError
import com.tangem.domain.addressbook.model.AddressBook
import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.model.*
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
@ -402,6 +393,155 @@ internal class DefaultAddressBookRepositoryTest {
assertThat(result).isEqualTo(bob)
}
@Test
fun `GIVEN stored version newer than supported WHEN getContacts THEN emits empty without decrypting`() = runTest {
// Arrange
val blob = createBlob(version = "2.0")
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
// Act
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isEmpty()
verify(exactly = 0) { cipher.decrypt(any(), any()) }
}
@Test
fun `GIVEN stored version older than supported WHEN getContacts THEN decrypts normally`() = runTest {
// Arrange
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob(version = "0.9")
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
// Act
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).containsExactly(contact)
}
@Test
fun `GIVEN stored version newer than supported WHEN saveContact THEN VersionMismatch and pushes nothing`() =
runTest {
// Arrange
val storedBlob = createBlob(version = "2.0")
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
// Act
val result = repository.saveContact(createContact(id = "c2", name = "Bob"))
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.VersionMismatch.left())
verify(exactly = 0) { cipher.decrypt(any(), any()) }
coVerify(exactly = 0) { addressBookApi.updateAddressBook(any(), any(), any()) }
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
}
@Test
fun `GIVEN stored version older than supported WHEN saveContact THEN pushes upgraded to current version`() =
runTest {
// Arrange
val storedBlob = createBlob(version = "0.9")
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns AddressBook(emptyList()).right()
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { blobStore.storeBlob(any()) } returns Unit
val bodySlot = slot<com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest>()
coEvery {
addressBookApi.updateAddressBook(WALLET_A, any(), capture(bodySlot))
} returns successPutResponse()
// Act
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
assertThat(result).isEqualTo(Unit.right())
assertThat(bodySlot.captured.version).isEqualTo(AddressBookBlob.CURRENT_VERSION)
}
@Test
fun `GIVEN contact wallet version newer than supported WHEN deleteContact THEN book left untouched`() = runTest {
// Arrange
val storedBlob = createBlob(version = "2.0")
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
// Act
val result = repository.deleteContact(ContactId("c2"))
// Assert — the newer book is skipped, so nothing is decrypted, pushed or stored.
assertThat(result).isEqualTo(Unit.right())
verify(exactly = 0) { cipher.decrypt(any(), any()) }
coVerify(exactly = 0) { addressBookApi.updateAddressBook(any(), any(), any()) }
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
}
@Test
fun `GIVEN no book WHEN isAddressBookCompatible for wallet THEN true`() = runTest {
// Arrange
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(null)
// Act
val result = repository.isAddressBookCompatible(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isTrue()
}
@Test
fun `GIVEN version at or below supported WHEN isAddressBookCompatible for wallet THEN true`() = runTest {
// Arrange
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(createBlob(version = "0.9"))
// Act
val result = repository.isAddressBookCompatible(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isTrue()
}
@Test
fun `GIVEN version newer than supported WHEN isAddressBookCompatible for wallet THEN false`() = runTest {
// Arrange
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(createBlob(version = "2.0"))
// Act
val result = repository.isAddressBookCompatible(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isFalse()
}
@Test
fun `GIVEN one wallet book newer WHEN isAddressBookCompatible for all THEN false`() = runTest {
// Arrange
val walletB: UserWallet = mockk { every { walletId } returns UserWalletId(WALLET_B) }
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet, walletB))
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A), UserWalletId(WALLET_B))) } returns
flowOf(listOf(createBlob(walletId = WALLET_A), createBlob(walletId = WALLET_B, version = "2.0")))
// Act
val result = repository.isAddressBookCompatible().first()
// Assert
assertThat(result).isFalse()
}
@Test
fun `GIVEN all wallet books supported WHEN isAddressBookCompatible for all THEN true`() = runTest {
// Arrange
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns
flowOf(listOf(createBlob(version = "0.9")))
// Act
val result = repository.isAddressBookCompatible().first()
// Assert
assertThat(result).isTrue()
}
private fun successPutResponse(etag: String = ETAG_NEW): ApiResponse<UpdateAddressBookResponse> =
ApiResponse.Success(
data = UpdateAddressBookResponse(walletId = WALLET_A, etag = etag, updatedAt = TIMESTAMP),
@ -438,8 +578,12 @@ internal class DefaultAddressBookRepositoryTest {
addresses = emptyList(),
)
private fun createBlob(): AddressBookBlob = AddressBookBlob(
walletId = WALLET_A,
private fun createBlob(
walletId: String = WALLET_A,
version: String = AddressBookBlob.CURRENT_VERSION,
): AddressBookBlob = AddressBookBlob(
version = version,
walletId = walletId,
updatedAt = TIMESTAMP,
nonce = "00112233445566778899aabb",
ciphertext = "deadbeef",