Updated on 2026-08-14
This commit is contained in:
parent
98d0d9c75e
commit
b1c19eff9e
21 changed files with 538 additions and 52 deletions
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue