Updated on 2026-08-14
This commit is contained in:
commit
31a8ee5dd4
18 changed files with 579 additions and 366 deletions
|
|
@ -1,65 +1,20 @@
|
|||
package com.tangem.domain.addressbook.interactor
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.right
|
||||
import com.tangem.domain.addressbook.model.AddressEntriesVerification
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.VerifiedContact
|
||||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.error.VerifyMessagesError
|
||||
import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
|
||||
import com.tangem.utils.extensions.hexToBytesOrNull
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class GetVerifiedContactsInteractor(
|
||||
private val getContacts: GetContactsUseCase,
|
||||
private val verifyMessages: VerifySecp256k1MessagesUseCase,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val contactSignatureVerifier: ContactSignatureVerifier,
|
||||
) {
|
||||
|
||||
operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow<List<VerifiedContact>> {
|
||||
fun getVerifiedContacts(query: String, userWalletId: UserWalletId? = null): Flow<List<VerifiedContact>> {
|
||||
return getContacts(query, userWalletId).map { contacts ->
|
||||
val walletsById = userWalletsListRepository.userWalletsSync().associateBy { it.walletId }
|
||||
contacts.mapNotNull { contact ->
|
||||
val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null
|
||||
val verification = verify(userWallet, contact).getOrNull() ?: return@mapNotNull null
|
||||
VerifiedContact(
|
||||
contact = contact.copy(addresses = verification.valid),
|
||||
invalidEntries = verification.invalid,
|
||||
)
|
||||
}
|
||||
contactSignatureVerifier.verifyContacts(contacts)
|
||||
}
|
||||
}
|
||||
|
||||
private fun verify(
|
||||
userWallet: UserWallet,
|
||||
contact: Contact,
|
||||
): Either<VerifyMessagesError, AddressEntriesVerification> {
|
||||
val entries = contact.addresses
|
||||
if (entries.isEmpty()) return AddressEntriesVerification(valid = emptyList(), invalid = emptyList()).right()
|
||||
|
||||
// Entries with a malformed (non-hex) signature can't be verified — they are invalid by format.
|
||||
val wellFormed = entries.mapNotNull { entry ->
|
||||
entry.signature.hexToBytesOrNull()?.let { signature -> entry to signature }
|
||||
}
|
||||
val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) }
|
||||
val signatures = wellFormed.map { (_, signature) -> signature }
|
||||
|
||||
return verifyMessages(userWallet = userWallet, messages = messages, signatures = signatures)
|
||||
.map { flags ->
|
||||
val validIds = wellFormed
|
||||
.filterIndexed { index, _ -> flags[index] }
|
||||
.mapTo(HashSet()) { (entry, _) -> entry.id }
|
||||
|
||||
AddressEntriesVerification(
|
||||
valid = entries.filter { it.id in validIds },
|
||||
invalid = entries.filterNot { it.id in validIds },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,8 @@ import com.tangem.domain.addressbook.model.ContactId
|
|||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
|
||||
import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload
|
||||
import com.tangem.domain.addressbook.validation.ContactNameValidator
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.transaction.error.SignHashesError
|
||||
import com.tangem.domain.transaction.usecase.SignUseCase
|
||||
|
|
@ -22,7 +22,7 @@ import java.util.UUID
|
|||
|
||||
class SaveContactInteractor(
|
||||
private val repository: AddressBookRepository,
|
||||
private val validateContactName: ValidateContactNameUseCase,
|
||||
private val validateContactName: ContactNameValidator,
|
||||
private val signUseCase: SignUseCase,
|
||||
private val timestampProvider: IsoTimestampProvider,
|
||||
) {
|
||||
|
|
@ -34,7 +34,7 @@ class SaveContactInteractor(
|
|||
addresses: List<AddressEntry>,
|
||||
): Either<SaveContactError, Contact> = either {
|
||||
val userWalletId = userWallet.walletId
|
||||
val validName = validateContactName(userWalletId, name)
|
||||
val validName = validateContactName.validate(userWalletId, name)
|
||||
.mapLeft(SaveContactError::Name)
|
||||
.bind()
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import kotlinx.serialization.Serializable
|
|||
*
|
||||
* The only way to obtain an instance is the validating [invoke] factory, which enforces the
|
||||
* address-book naming rules. Uniqueness within a wallet is **not** enforced here — it requires
|
||||
* access to the repository and lives in `ValidateContactNameUseCase`.
|
||||
* access to the repository and lives in `ContactNameValidator`.
|
||||
*/
|
||||
@Serializable
|
||||
@ConsistentCopyVisibility
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import arrow.core.raise.ensure
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/**
|
||||
* Validates a contact name: format rules via [ContactName] plus case-insensitive uniqueness within
|
||||
* the wallet.
|
||||
*/
|
||||
class ValidateContactNameUseCase(
|
||||
private val repository: AddressBookRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
walletId: UserWalletId,
|
||||
name: String,
|
||||
): Either<ContactNameValidationError, ContactName> = either {
|
||||
val validName = ContactName(name)
|
||||
.mapLeft(ContactNameValidationError::Format)
|
||||
.bind()
|
||||
|
||||
val contacts = repository.getContacts(walletId).first()
|
||||
val isDuplicate = contacts.any { contact ->
|
||||
contact.name.value.equals(validName.value, ignoreCase = true)
|
||||
}
|
||||
ensure(!isDuplicate) { ContactNameValidationError.Duplicate }
|
||||
|
||||
validName
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.tangem.domain.addressbook.validation
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import arrow.core.raise.ensure
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Validates a contact name: format rules via [ContactName] plus case-insensitive uniqueness within the
|
||||
* wallet.
|
||||
*
|
||||
* Uniqueness is enforced only against **verified** contacts (see [ContactSignatureVerifier.isNameVerified]):
|
||||
* a spoofed or tampered contact synced from another device must not be able to reserve a name. Reads the
|
||||
* local snapshot via [AddressBookRepository.getContactsSync] (validation runs on live keystrokes) and
|
||||
* filters to same-name contacts before verifying, so signature checks fire only on an actual collision.
|
||||
*/
|
||||
class ContactNameValidator(
|
||||
private val repository: AddressBookRepository,
|
||||
private val contactSignatureVerifier: ContactSignatureVerifier,
|
||||
) {
|
||||
|
||||
suspend fun validate(walletId: UserWalletId, name: String): Either<ContactNameValidationError, ContactName> =
|
||||
either {
|
||||
val validName = ContactName(name)
|
||||
.mapLeft(ContactNameValidationError::Format)
|
||||
.bind()
|
||||
|
||||
val sameName = repository.getContactsSync(walletId)
|
||||
.filter { it.name.value.equals(validName.value, ignoreCase = true) }
|
||||
val isDuplicate = sameName.any { contactSignatureVerifier.isNameVerified(it) }
|
||||
ensure(!isDuplicate) { ContactNameValidationError.Duplicate }
|
||||
|
||||
validName
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.tangem.domain.addressbook.verification
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.right
|
||||
import com.tangem.domain.addressbook.model.AddressEntriesVerification
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.VerifiedContact
|
||||
import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.transaction.error.VerifyMessagesError
|
||||
import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
|
||||
import com.tangem.utils.extensions.hexToBytesOrNull
|
||||
|
||||
class ContactSignatureVerifier(
|
||||
private val verifyMessages: VerifySecp256k1MessagesUseCase,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
) {
|
||||
|
||||
suspend fun verifyContacts(contacts: List<Contact>): List<VerifiedContact> {
|
||||
val walletsById = userWalletsListRepository.userWalletsSync().associateBy { it.walletId }
|
||||
return contacts.mapNotNull { contact ->
|
||||
val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null
|
||||
val verification = verify(userWallet, contact).getOrNull() ?: return@mapNotNull null
|
||||
VerifiedContact(
|
||||
contact = contact.copy(addresses = verification.valid),
|
||||
invalidEntries = verification.invalid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isNameVerified(contact: Contact): Boolean {
|
||||
val userWallet = userWalletsListRepository.userWalletsSync()
|
||||
.firstOrNull { it.walletId == contact.walletId } ?: return false
|
||||
return verify(userWallet, contact).getOrNull()?.valid?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
private fun verify(
|
||||
userWallet: UserWallet,
|
||||
contact: Contact,
|
||||
): Either<VerifyMessagesError, AddressEntriesVerification> {
|
||||
val entries = contact.addresses
|
||||
if (entries.isEmpty()) return AddressEntriesVerification(valid = emptyList(), invalid = emptyList()).right()
|
||||
|
||||
// Entries with a malformed (non-hex) signature can't be verified — they are invalid by format.
|
||||
val wellFormed = entries.mapNotNull { entry ->
|
||||
entry.signature.hexToBytesOrNull()?.let { signature -> entry to signature }
|
||||
}
|
||||
val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) }
|
||||
val signatures = wellFormed.map { (_, signature) -> signature }
|
||||
|
||||
return verifyMessages(userWallet = userWallet, messages = messages, signatures = signatures)
|
||||
.map { flags ->
|
||||
val validIds = wellFormed
|
||||
.filterIndexed { index, _ -> flags[index] }
|
||||
.mapTo(HashSet()) { (entry, _) -> entry.id }
|
||||
|
||||
AddressEntriesVerification(
|
||||
valid = entries.filter { it.id in validIds },
|
||||
invalid = entries.filterNot { it.id in validIds },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
package com.tangem.domain.addressbook.interactor
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.AddressEntryId
|
||||
|
|
@ -10,19 +8,14 @@ import com.tangem.domain.addressbook.model.ContactId
|
|||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.model.VerifiedContact
|
||||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.error.VerifyMessagesError
|
||||
import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
|
||||
import com.tangem.utils.extensions.toHexString
|
||||
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.verify
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -34,157 +27,37 @@ import org.junit.jupiter.api.TestInstance
|
|||
class GetVerifiedContactsInteractorTest {
|
||||
|
||||
private val getContacts: GetContactsUseCase = mockk()
|
||||
private val verifyMessages: VerifySecp256k1MessagesUseCase = mockk()
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
||||
private val contactSignatureVerifier: ContactSignatureVerifier = mockk()
|
||||
|
||||
private val interactor = GetVerifiedContactsInteractor(
|
||||
getContacts = getContacts,
|
||||
verifyMessages = verifyMessages,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
contactSignatureVerifier = contactSignatureVerifier,
|
||||
)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
private val userWallet: UserWallet = mockk { every { walletId } returns this@GetVerifiedContactsInteractorTest.walletId }
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(getContacts, verifyMessages, userWalletsListRepository)
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
|
||||
clearMocks(getContacts, contactSignatureVerifier)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN mixed entries WHEN invoke THEN displays only valid AND keeps invalid for analytics`() = runTest {
|
||||
// Arrange
|
||||
val valid = entry(id = "valid", address = "0xvalid", memo = null, signature = "AABB")
|
||||
val invalid = entry(id = "invalid", address = "0xinvalid", memo = null, signature = "CCDD")
|
||||
val contact = contact(valid, invalid)
|
||||
stubContacts(contact)
|
||||
every { verifyMessages(any(), any(), any()) } returns listOf(true, false).right()
|
||||
|
||||
// Act
|
||||
val result = interactor(query = "").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(
|
||||
VerifiedContact(
|
||||
contact = contact.copy(addresses = listOf(valid)),
|
||||
invalidEntries = listOf(invalid),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with entries WHEN invoke THEN verifies each entry payload and its signature`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact(
|
||||
entry(id = "addr-1", address = "0xabc", memo = "memo", signature = "AABB"),
|
||||
entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"),
|
||||
)
|
||||
stubContacts(contact)
|
||||
val messagesSlot = slot<List<ByteArray>>()
|
||||
val signaturesSlot = slot<List<ByteArray>>()
|
||||
every {
|
||||
verifyMessages(eq(userWallet), capture(messagesSlot), capture(signaturesSlot))
|
||||
} returns listOf(true, true).right()
|
||||
|
||||
// Act
|
||||
interactor(query = "").first()
|
||||
|
||||
// Assert
|
||||
assertThat(messagesSlot.captured.map { String(it) })
|
||||
.containsExactly(
|
||||
expectedPayload(contact, contact.addresses[0]),
|
||||
expectedPayload(contact, contact.addresses[1]),
|
||||
)
|
||||
.inOrder()
|
||||
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN some entries fail verification WHEN invoke THEN partitions them preserving order`() = runTest {
|
||||
// Arrange
|
||||
val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")
|
||||
val invalid = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD")
|
||||
val valid2 = entry(id = "addr-3", address = "0xghi", memo = null, signature = "EEFF")
|
||||
val contact = contact(valid1, invalid, valid2)
|
||||
stubContacts(contact)
|
||||
every { verifyMessages(any(), any(), any()) } returns listOf(true, false, true).right()
|
||||
|
||||
// Act
|
||||
val result = interactor(query = "").first().single()
|
||||
|
||||
// Assert
|
||||
assertThat(result.contact.addresses).containsExactly(valid1, valid2).inOrder()
|
||||
assertThat(result.invalidEntries).containsExactly(invalid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN malformed signature WHEN invoke THEN that entry is invalid and excluded from verification`() = runTest {
|
||||
// Arrange
|
||||
val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex")
|
||||
val signed = entry(id = "addr-2", address = "0xdef", memo = null, signature = "AABB")
|
||||
val contact = contact(malformed, signed)
|
||||
stubContacts(contact)
|
||||
val signaturesSlot = slot<List<ByteArray>>()
|
||||
every {
|
||||
verifyMessages(eq(userWallet), any(), capture(signaturesSlot))
|
||||
} returns listOf(true).right()
|
||||
|
||||
// Act
|
||||
val result = interactor(query = "").first().single()
|
||||
|
||||
// Assert
|
||||
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB")
|
||||
assertThat(result.contact.addresses).containsExactly(signed)
|
||||
assertThat(result.invalidEntries).containsExactly(malformed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with no entries WHEN invoke THEN keeps contact without verifying`() = runTest {
|
||||
fun `GIVEN contacts WHEN getVerifiedContacts THEN maps them through the verifier`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact()
|
||||
stubContacts(contact)
|
||||
val verified = VerifiedContact(contact = contact, invalidEntries = emptyList())
|
||||
every { getContacts(query = "query", userWalletId = walletId) } returns flowOf(listOf(contact))
|
||||
coEvery { contactSignatureVerifier.verifyContacts(listOf(contact)) } returns listOf(verified)
|
||||
|
||||
// Act
|
||||
val result = interactor(query = "").first().single()
|
||||
val result = interactor.getVerifiedContacts(query = "query", userWalletId = walletId).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result.contact.addresses).isEmpty()
|
||||
assertThat(result.invalidEntries).isEmpty()
|
||||
verify(exactly = 0) { verifyMessages(any(), any(), any()) }
|
||||
assertThat(result).containsExactly(verified)
|
||||
coVerify(exactly = 1) { contactSignatureVerifier.verifyContacts(listOf(contact)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN wallet cannot be resolved WHEN invoke THEN contact is dropped`() = runTest {
|
||||
// Arrange
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
|
||||
stubContacts(contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")))
|
||||
|
||||
// Act
|
||||
val result = interactor(query = "").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN verification fails WHEN invoke THEN contact is dropped`() = runTest {
|
||||
// Arrange
|
||||
stubContacts(contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")))
|
||||
every { verifyMessages(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left()
|
||||
|
||||
// Act
|
||||
val result = interactor(query = "").first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
private fun stubContacts(vararg contacts: Contact) {
|
||||
every { getContacts(query = "", userWalletId = null) } returns flowOf(contacts.toList())
|
||||
}
|
||||
|
||||
private fun contact(vararg entries: AddressEntry): Contact = Contact(
|
||||
private fun contact(): Contact = Contact(
|
||||
id = ContactId("contact-1"),
|
||||
walletId = walletId,
|
||||
name = requireNotNull(ContactName("Alice").getOrNull()),
|
||||
|
|
@ -192,18 +65,15 @@ class GetVerifiedContactsInteractorTest {
|
|||
iconColor = "KekColor",
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addresses = entries.toList(),
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-1"),
|
||||
address = "0xabc",
|
||||
networkId = Network.RawID("ethereum"),
|
||||
networkName = "Ethereum",
|
||||
memo = null,
|
||||
signature = "AABB",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun entry(id: String, address: String, memo: String?, signature: String): AddressEntry = AddressEntry(
|
||||
id = AddressEntryId(id),
|
||||
address = address,
|
||||
networkId = Network.RawID("ethereum"),
|
||||
networkName = "Ethereum",
|
||||
memo = memo,
|
||||
signature = signature,
|
||||
)
|
||||
|
||||
private fun expectedPayload(contact: Contact, entry: AddressEntry): String =
|
||||
entry.address + entry.networkId.value + entry.memo.orEmpty() + contact.id.value + contact.name.value
|
||||
}
|
||||
|
|
@ -14,10 +14,9 @@ import com.tangem.domain.addressbook.model.ContactId
|
|||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
|
||||
import com.tangem.domain.addressbook.validation.ContactNameValidator
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.error.SignHashesError
|
||||
import com.tangem.domain.transaction.usecase.SignUseCase
|
||||
import com.tangem.utils.extensions.toHexString
|
||||
|
|
@ -27,7 +26,6 @@ import io.mockk.coVerify
|
|||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
|
|
@ -39,13 +37,14 @@ import java.security.MessageDigest
|
|||
internal class SaveContactInteractorTest {
|
||||
|
||||
private val repository: AddressBookRepository = mockk(relaxUnitFun = true)
|
||||
private val contactNameValidator: ContactNameValidator = mockk()
|
||||
private val signUseCase: SignUseCase = mockk()
|
||||
private val timestampProvider: IsoTimestampProvider = mockk {
|
||||
every { now() } returns NEW_TIMESTAMP
|
||||
}
|
||||
private val interactor = SaveContactInteractor(
|
||||
repository = repository,
|
||||
validateContactName = ValidateContactNameUseCase(repository),
|
||||
validateContactName = contactNameValidator,
|
||||
signUseCase = signUseCase,
|
||||
timestampProvider = timestampProvider,
|
||||
)
|
||||
|
|
@ -57,7 +56,7 @@ internal class SaveContactInteractorTest {
|
|||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository, signUseCase, answers = false)
|
||||
clearMocks(repository, contactNameValidator, signUseCase, answers = false)
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
|
@ -69,7 +68,7 @@ internal class SaveContactInteractorTest {
|
|||
@Test
|
||||
fun `GIVEN unique name WHEN createContact THEN generates ids AND persists the signed contact`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
stubValidName(name = "Alice")
|
||||
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()))
|
||||
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns
|
||||
signatures.right()
|
||||
|
|
@ -95,7 +94,7 @@ internal class SaveContactInteractorTest {
|
|||
fun `GIVEN entries WHEN createContact THEN signs each with the wallet key over the canonical payload`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
stubValidName(name = "Alice")
|
||||
val twoEntries = listOf(
|
||||
entry(id = "addr-1", address = "0xabc", memo = "memo"),
|
||||
entry(id = "addr-2", address = "0xdef", memo = null),
|
||||
|
|
@ -129,7 +128,7 @@ internal class SaveContactInteractorTest {
|
|||
@Test
|
||||
fun `GIVEN no entries WHEN createContact THEN persists without signing`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
stubValidName(name = "Alice")
|
||||
val saved = slot<Contact>()
|
||||
coEvery { repository.saveContact(capture(saved)) } returns Unit.right()
|
||||
|
||||
|
|
@ -150,7 +149,7 @@ internal class SaveContactInteractorTest {
|
|||
every { walletId } returns userWallet.walletId
|
||||
every { wallets } returns null
|
||||
}
|
||||
stubNoExistingContacts()
|
||||
stubValidName(name = "Alice")
|
||||
|
||||
// Act
|
||||
val result = interactor.createContact(lockedWallet, name = "Alice", iconColor = "TestColor", entries)
|
||||
|
|
@ -164,7 +163,7 @@ internal class SaveContactInteractorTest {
|
|||
@Test
|
||||
fun `GIVEN signUseCase fails WHEN createContact THEN propagates Signing error without persisting`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
stubValidName(name = "Alice")
|
||||
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
|
||||
SignHashesError.SigningFailed(message = "canceled").left()
|
||||
|
||||
|
|
@ -180,7 +179,8 @@ internal class SaveContactInteractorTest {
|
|||
@Test
|
||||
fun `GIVEN duplicate name WHEN createContact THEN Name Duplicate without persisting`() = runTest {
|
||||
// Arrange
|
||||
every { repository.getContacts(userWallet.walletId) } returns flowOf(listOf(contact(name = "Alice")))
|
||||
coEvery { contactNameValidator.validate(userWallet.walletId, "alice") } returns
|
||||
ContactNameValidationError.Duplicate.left()
|
||||
|
||||
// Act
|
||||
val result = interactor.createContact(userWallet, name = "alice", iconColor = "TestColor", entries)
|
||||
|
|
@ -194,7 +194,8 @@ internal class SaveContactInteractorTest {
|
|||
@Test
|
||||
fun `GIVEN blank name WHEN createContact THEN Name Format without persisting`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
coEvery { contactNameValidator.validate(userWallet.walletId, "") } returns
|
||||
ContactNameValidationError.Format(ContactName.Error.Empty).left()
|
||||
|
||||
// Act
|
||||
val result = interactor.createContact(userWallet, name = "", iconColor = "TestColor", entries)
|
||||
|
|
@ -208,7 +209,7 @@ internal class SaveContactInteractorTest {
|
|||
@Test
|
||||
fun `GIVEN backend rejects the save WHEN createContact THEN Backend error is propagated`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
stubValidName(name = "Alice")
|
||||
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
|
||||
listOf(byteArrayOf(0x01)).right()
|
||||
coEvery { repository.saveContact(any()) } returns AddressBookSyncError.Conflict.left()
|
||||
|
|
@ -221,8 +222,9 @@ internal class SaveContactInteractorTest {
|
|||
.isEqualTo(SaveContactError.Backend(AddressBookSyncError.Conflict))
|
||||
}
|
||||
|
||||
private fun stubNoExistingContacts() {
|
||||
every { repository.getContacts(userWallet.walletId) } returns flowOf(emptyList())
|
||||
private fun stubValidName(name: String) {
|
||||
coEvery { contactNameValidator.validate(userWallet.walletId, name) } returns
|
||||
requireNotNull(ContactName(name).getOrNull()).right()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,7 +263,7 @@ internal class SaveContactInteractorTest {
|
|||
assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP)
|
||||
assertThat(contact.addresses.map { it.signature })
|
||||
.containsExactly(signatures[0].toHexString())
|
||||
coVerify(exactly = 0) { repository.getContacts(any<UserWalletId>()) }
|
||||
coVerify(exactly = 0) { contactNameValidator.validate(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.AddressEntryId
|
||||
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.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class ValidateContactNameUseCaseTest {
|
||||
|
||||
private val repository: AddressBookRepository = mockk(relaxUnitFun = true)
|
||||
private val useCase = ValidateContactNameUseCase(repository)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `format error is propagated`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(emptyList())
|
||||
|
||||
val result = useCase(walletId, name = "")
|
||||
|
||||
assertThat(result.leftOrNull())
|
||||
.isEqualTo(ContactNameValidationError.Format(ContactName.Error.Empty))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate name in same wallet is rejected case-insensitively`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice")))
|
||||
|
||||
val result = useCase(walletId, name = "alice")
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(ContactNameValidationError.Duplicate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unique name is accepted`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice")))
|
||||
|
||||
val result = useCase(walletId, name = "Bob")
|
||||
|
||||
assertThat(result.getOrNull()?.value).isEqualTo("Bob")
|
||||
}
|
||||
|
||||
private fun contact(name: String): Contact = Contact(
|
||||
id = ContactId("id-$name"),
|
||||
walletId = walletId,
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
icon = "",
|
||||
iconColor = "KekColor",
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = "0xabc",
|
||||
networkId = Network.RawID("ethereum"),
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
package com.tangem.domain.addressbook.validation
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.AddressEntryId
|
||||
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.repository.AddressBookRepository
|
||||
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class ContactNameValidatorTest {
|
||||
|
||||
private val repository: AddressBookRepository = mockk()
|
||||
private val contactSignatureVerifier: ContactSignatureVerifier = mockk()
|
||||
|
||||
private val validator = ContactNameValidator(
|
||||
repository = repository,
|
||||
contactSignatureVerifier = contactSignatureVerifier,
|
||||
)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository, contactSignatureVerifier)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blank name WHEN validate THEN format error is propagated`() = runTest {
|
||||
// Act
|
||||
val result = validator.validate(walletId, name = "")
|
||||
|
||||
// Assert
|
||||
assertThat(result.leftOrNull())
|
||||
.isEqualTo(ContactNameValidationError.Format(ContactName.Error.Empty))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN same-name verified contact WHEN validate THEN Duplicate rejected case-insensitively`() = runTest {
|
||||
// Arrange
|
||||
coEvery { repository.getContactsSync(walletId) } returns listOf(contact(name = "Alice"))
|
||||
coEvery { contactSignatureVerifier.isNameVerified(any()) } returns true
|
||||
|
||||
// Act
|
||||
val result = validator.validate(walletId, name = "alice")
|
||||
|
||||
// Assert
|
||||
assertThat(result.leftOrNull()).isEqualTo(ContactNameValidationError.Duplicate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN same-name but unverified spoofed contact WHEN validate THEN name is accepted`() = runTest {
|
||||
// Arrange — a contact synced from another device whose signature does not verify must not reserve a name
|
||||
coEvery { repository.getContactsSync(walletId) } returns listOf(contact(name = "Alice"))
|
||||
coEvery { contactSignatureVerifier.isNameVerified(any()) } returns false
|
||||
|
||||
// Act
|
||||
val result = validator.validate(walletId, name = "alice")
|
||||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()?.value).isEqualTo("alice")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no same-name contacts WHEN validate THEN accepted without verifying`() = runTest {
|
||||
// Arrange
|
||||
coEvery { repository.getContactsSync(walletId) } returns listOf(contact(name = "Alice"))
|
||||
|
||||
// Act
|
||||
val result = validator.validate(walletId, name = "Bob")
|
||||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()?.value).isEqualTo("Bob")
|
||||
coVerify(exactly = 0) { contactSignatureVerifier.isNameVerified(any()) }
|
||||
}
|
||||
|
||||
private fun contact(name: String): Contact = Contact(
|
||||
id = ContactId("id-$name"),
|
||||
walletId = walletId,
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
icon = "",
|
||||
iconColor = "KekColor",
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = "0xabc",
|
||||
networkId = Network.RawID("ethereum"),
|
||||
memo = null,
|
||||
signature = "AABB",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
package com.tangem.domain.addressbook.verification
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.AddressEntryId
|
||||
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.VerifiedContact
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.error.VerifyMessagesError
|
||||
import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase
|
||||
import com.tangem.utils.extensions.toHexString
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class ContactSignatureVerifierTest {
|
||||
|
||||
private val verifyMessages: VerifySecp256k1MessagesUseCase = mockk()
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
||||
|
||||
private val verifier = ContactSignatureVerifier(
|
||||
verifyMessages = verifyMessages,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
private val userWallet: UserWallet = mockk { every { walletId } returns this@ContactSignatureVerifierTest.walletId }
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(verifyMessages, userWalletsListRepository)
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class VerifyContacts {
|
||||
|
||||
@Test
|
||||
fun `GIVEN mixed entries WHEN verifyContacts THEN displays only valid AND keeps invalid for analytics`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val valid = entry(id = "valid", address = "0xvalid", memo = null, signature = "AABB")
|
||||
val invalid = entry(id = "invalid", address = "0xinvalid", memo = null, signature = "CCDD")
|
||||
val contact = contact(valid, invalid)
|
||||
every { verifyMessages(any(), any(), any()) } returns listOf(true, false).right()
|
||||
|
||||
// Act
|
||||
val result = verifier.verifyContacts(listOf(contact))
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(
|
||||
VerifiedContact(
|
||||
contact = contact.copy(addresses = listOf(valid)),
|
||||
invalidEntries = listOf(invalid),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with entries WHEN verifyContacts THEN verifies each entry payload and its signature`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val contact = contact(
|
||||
entry(id = "addr-1", address = "0xabc", memo = "memo", signature = "AABB"),
|
||||
entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"),
|
||||
)
|
||||
val messagesSlot = slot<List<ByteArray>>()
|
||||
val signaturesSlot = slot<List<ByteArray>>()
|
||||
every {
|
||||
verifyMessages(eq(userWallet), capture(messagesSlot), capture(signaturesSlot))
|
||||
} returns listOf(true, true).right()
|
||||
|
||||
// Act
|
||||
verifier.verifyContacts(listOf(contact))
|
||||
|
||||
// Assert
|
||||
assertThat(messagesSlot.captured.map { String(it) })
|
||||
.containsExactly(
|
||||
expectedPayload(contact, contact.addresses[0]),
|
||||
expectedPayload(contact, contact.addresses[1]),
|
||||
)
|
||||
.inOrder()
|
||||
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN some entries fail verification WHEN verifyContacts THEN partitions them preserving order`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")
|
||||
val invalid = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD")
|
||||
val valid2 = entry(id = "addr-3", address = "0xghi", memo = null, signature = "EEFF")
|
||||
val contact = contact(valid1, invalid, valid2)
|
||||
every { verifyMessages(any(), any(), any()) } returns listOf(true, false, true).right()
|
||||
|
||||
// Act
|
||||
val result = verifier.verifyContacts(listOf(contact)).single()
|
||||
|
||||
// Assert
|
||||
assertThat(result.contact.addresses).containsExactly(valid1, valid2).inOrder()
|
||||
assertThat(result.invalidEntries).containsExactly(invalid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN malformed signature WHEN verifyContacts THEN that entry is invalid and excluded from verification`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex")
|
||||
val signed = entry(id = "addr-2", address = "0xdef", memo = null, signature = "AABB")
|
||||
val contact = contact(malformed, signed)
|
||||
val signaturesSlot = slot<List<ByteArray>>()
|
||||
every {
|
||||
verifyMessages(eq(userWallet), any(), capture(signaturesSlot))
|
||||
} returns listOf(true).right()
|
||||
|
||||
// Act
|
||||
val result = verifier.verifyContacts(listOf(contact)).single()
|
||||
|
||||
// Assert
|
||||
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB")
|
||||
assertThat(result.contact.addresses).containsExactly(signed)
|
||||
assertThat(result.invalidEntries).containsExactly(malformed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with no entries WHEN verifyContacts THEN keeps contact without verifying`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact()
|
||||
|
||||
// Act
|
||||
val result = verifier.verifyContacts(listOf(contact)).single()
|
||||
|
||||
// Assert
|
||||
assertThat(result.contact.addresses).isEmpty()
|
||||
assertThat(result.invalidEntries).isEmpty()
|
||||
verify(exactly = 0) { verifyMessages(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN wallet cannot be resolved WHEN verifyContacts THEN contact is dropped`() = runTest {
|
||||
// Arrange
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
|
||||
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))
|
||||
|
||||
// Act
|
||||
val result = verifier.verifyContacts(listOf(contact))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN verification fails WHEN verifyContacts THEN contact is dropped`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))
|
||||
every { verifyMessages(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left()
|
||||
|
||||
// Act
|
||||
val result = verifier.verifyContacts(listOf(contact))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class IsNameVerified {
|
||||
|
||||
@Test
|
||||
fun `GIVEN at least one valid entry WHEN isNameVerified THEN true`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact(
|
||||
entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"),
|
||||
entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"),
|
||||
)
|
||||
every { verifyMessages(any(), any(), any()) } returns listOf(false, true).right()
|
||||
|
||||
// Act & Assert
|
||||
assertThat(verifier.isNameVerified(contact)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN all entries invalid WHEN isNameVerified THEN false`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))
|
||||
every { verifyMessages(any(), any(), any()) } returns listOf(false).right()
|
||||
|
||||
// Act & Assert
|
||||
assertThat(verifier.isNameVerified(contact)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with no entries WHEN isNameVerified THEN false`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact()
|
||||
|
||||
// Act & Assert
|
||||
assertThat(verifier.isNameVerified(contact)).isFalse()
|
||||
verify(exactly = 0) { verifyMessages(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN wallet cannot be resolved WHEN isNameVerified THEN false`() = runTest {
|
||||
// Arrange
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
|
||||
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))
|
||||
|
||||
// Act & Assert
|
||||
assertThat(verifier.isNameVerified(contact)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN verification fails WHEN isNameVerified THEN false`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))
|
||||
every { verifyMessages(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left()
|
||||
|
||||
// Act & Assert
|
||||
assertThat(verifier.isNameVerified(contact)).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
private fun contact(vararg entries: AddressEntry): Contact = Contact(
|
||||
id = ContactId("contact-1"),
|
||||
walletId = walletId,
|
||||
name = requireNotNull(ContactName("Alice").getOrNull()),
|
||||
icon = "",
|
||||
iconColor = "KekColor",
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addresses = entries.toList(),
|
||||
)
|
||||
|
||||
private fun entry(id: String, address: String, memo: String?, signature: String): AddressEntry = AddressEntry(
|
||||
id = AddressEntryId(id),
|
||||
address = address,
|
||||
networkId = Network.RawID("ethereum"),
|
||||
networkName = "Ethereum",
|
||||
memo = memo,
|
||||
signature = signature,
|
||||
)
|
||||
|
||||
private fun expectedPayload(contact: Contact, entry: AddressEntry): String =
|
||||
entry.address + entry.networkId.value + entry.memo.orEmpty() + contact.id.value + contact.name.value
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue