diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5f9f07af38..cacdeb494a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -183,7 +183,9 @@ dependencies { } /** Testing libraries */ + testImplementation(deps.test.coroutine) testImplementation(deps.test.junit) + testImplementation(deps.test.mockk) testImplementation(deps.test.truth) androidTestImplementation(deps.test.junit.android) androidTestImplementation(deps.test.espresso) @@ -205,7 +207,7 @@ dependencies { /** Excluded dependencies */ implementation("com.google.guava:guava:30.0-android") { // excludes version 9999.0-empty-to-avoid-conflict-with-guava - exclude(group="com.google.guava", module = "listenablefuture") + exclude(group = "com.google.guava", module = "listenablefuture") } } \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt b/app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt new file mode 100644 index 0000000000..43c27150f4 --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt @@ -0,0 +1,88 @@ +package com.tangem.tap.domain.card + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.Token +import com.tangem.data.tokens.utils.CryptoCurrencyFactory +import com.tangem.domain.common.util.derivationStyleProvider +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network + +/** +[REDACTED_AUTHOR] + */ +internal class CryptoCurrenciesMocks(private val scanResponse: ScanResponse) { + + private val factory = CryptoCurrencyFactory() + + val cardano by lazy { listOf(createCoin(blockchain = Blockchain.Cardano)) } + val chia by lazy { listOf(element = createCoin(Blockchain.Chia)) } + val ethereum by lazy { listOf(element = createCoin(Blockchain.Ethereum)) } + + val chiaAndEthereum by lazy { + listOf( + createCoin(blockchain = Blockchain.Chia), + createCoin(blockchain = Blockchain.Ethereum), + ) + } + + val ethereumAndStellar by lazy { + listOf( + createCoin(blockchain = Blockchain.Ethereum), + createCoin(blockchain = Blockchain.Stellar), + ) + } + + val ethereumTokenWithBinanceDerivation by lazy { + listOf( + createCustomToken(blockchain = Blockchain.Ethereum, derivationBlockchain = Blockchain.Binance), + ) + } + + private fun createCoin(blockchain: Blockchain): CryptoCurrency { + return factory.createCoin( + blockchain = blockchain, + extraDerivationPath = null, + derivationStyleProvider = scanResponse.derivationStyleProvider, + )!! + } + + // Impossible to create custom token by CryptoCurrencyFactory because it works with URI under the hood + private fun createCustomToken(blockchain: Blockchain, derivationBlockchain: Blockchain): CryptoCurrency { + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(blockchain.id), + suffix = CryptoCurrency.ID.Suffix.RawID(blockchain.id), + ), + network = Network( + id = Network.ID(value = blockchain.id), + backendId = "NEVER-MIND", + name = blockchain.fullName, + currencySymbol = "NEVER-MIND", + derivationPath = Network.DerivationPath.Custom( + value = derivationBlockchain.derivationPath( + scanResponse.derivationStyleProvider.getDerivationStyle(), + )!!.rawPath, + ), + isTestnet = false, + standardType = Network.StandardType.ERC20, + ), + name = "NEVER-MIND", + symbol = "NEVER-MIND", + decimals = 8, + iconUrl = null, + isCustom = false, + contractAddress = "NEVER-MIND", + ) + } + + private fun createToken(blockchain: Blockchain): CryptoCurrency { + return factory.createToken( + sdkToken = Token(symbol = "NEVER-MIND", contractAddress = "NEVER-MIND", decimals = 8), + blockchain = blockchain, + extraDerivationPath = null, + derivationStyleProvider = scanResponse.derivationStyleProvider, + )!! + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/domain/card/DefaultDerivationsRepositoryTest.kt b/app/src/test/kotlin/com/tangem/tap/domain/card/DefaultDerivationsRepositoryTest.kt new file mode 100644 index 0000000000..e818b118ab --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/domain/card/DefaultDerivationsRepositoryTest.kt @@ -0,0 +1,161 @@ +package com.tangem.tap.domain.card + +import android.annotation.SuppressLint +import com.google.common.truth.Truth +import com.tangem.common.CompletionResult +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.card.ScanCardException +import com.tangem.domain.common.configs.GenericCardConfig +import com.tangem.domain.common.configs.MultiWalletCardConfig +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.tap.domain.TangemSdkManager +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultDerivationsRepositoryTest { + + private val tangemSdkManager = mockk() + private val userWalletsStore = mockk() + private val repository = DefaultDerivationsRepository( + tangemSdkManager = tangemSdkManager, + userWalletsStore = userWalletsStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val defaultUserWalletId = UserWalletId("011") + private val defaultUserWallet = UserWallet( + name = "", + walletId = defaultUserWalletId, + artworkUrl = "", + cardsInWallet = setOf(), + isMultiCurrency = false, + scanResponse = ScanResponseMockFactory.create(cardConfig = GenericCardConfig, derivedKeys = emptyMap()), + ) + + @Test + fun `error if userWalletId not found`() = runTest { + coEvery { userWalletsStore.getSyncOrNull(defaultUserWalletId) } returns null + + runCatching { + repository.derivePublicKeys(userWalletId = defaultUserWalletId, currencies = emptyList()) + } + .onSuccess { error("Should throws exception") } + .onFailure { Truth.assertThat(it).isInstanceOf(IllegalStateException::class.java) } + + coVerify(exactly = 1) { userWalletsStore.getSyncOrNull(defaultUserWalletId) } + coVerify(inverse = true) { tangemSdkManager.derivePublicKeys(null, any()) } + coVerify(inverse = true) { userWalletsStore.update(defaultUserWalletId, any()) } + } + + @SuppressLint("CheckResult") + @Test + fun `success if card is not supported derivations`() = runTest { + coEvery { userWalletsStore.getSyncOrNull(defaultUserWalletId) } returns defaultUserWallet + + runCatching { repository.derivePublicKeys(userWalletId = defaultUserWalletId, currencies = emptyList()) } + .onSuccess { Truth.assertThat(it) } + .onFailure { error("Should returns success") } + + coVerify(exactly = 1) { userWalletsStore.getSyncOrNull(defaultUserWalletId) } + coVerify(inverse = true) { tangemSdkManager.derivePublicKeys(null, any()) } + coVerify(inverse = true) { userWalletsStore.update(defaultUserWalletId, any()) } + } + + @SuppressLint("CheckResult") + @Test + fun `success if currencies is empty`() = runTest { + val userWallet = defaultUserWallet.copy( + scanResponse = ScanResponseMockFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()), + ) + coEvery { userWalletsStore.getSyncOrNull(defaultUserWalletId) } returns userWallet + + runCatching { repository.derivePublicKeys(userWalletId = defaultUserWalletId, currencies = emptyList()) } + .onSuccess { Truth.assertThat(it) } + .onFailure { error("Should returns success") } + + coVerify(exactly = 1) { userWalletsStore.getSyncOrNull(defaultUserWalletId) } + coVerify(inverse = true) { tangemSdkManager.derivePublicKeys(null, any()) } + coVerify(inverse = true) { userWalletsStore.update(defaultUserWalletId, any()) } + } + + @SuppressLint("CheckResult") + @Test + fun `success if card already has derivations`() = runTest { + val userWallet = defaultUserWallet.copy( + scanResponse = ScanResponseMockFactory.create( + cardConfig = MultiWalletCardConfig, + derivedKeys = DerivedKeysMocks.ethereumDerivedKeys, + ), + ) + + coEvery { userWalletsStore.getSyncOrNull(defaultUserWalletId) } returns userWallet + + runCatching { + repository.derivePublicKeys( + userWalletId = defaultUserWalletId, + currencies = CryptoCurrenciesMocks(userWallet.scanResponse).ethereum, + ) + } + .onSuccess { Truth.assertThat(it) } + .onFailure { error("Should returns success") } + + coVerify(exactly = 1) { userWalletsStore.getSyncOrNull(defaultUserWalletId) } + coVerify(inverse = true) { tangemSdkManager.derivePublicKeys(null, any()) } + coVerify(inverse = true) { userWalletsStore.update(defaultUserWalletId, any()) } + } + + @Test + fun `error if tangemSdkManager throws exception`() = runTest { + val userWallet = defaultUserWallet.copy( + scanResponse = ScanResponseMockFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()), + ) + coEvery { userWalletsStore.getSyncOrNull(defaultUserWalletId) } returns userWallet + coEvery { tangemSdkManager.derivePublicKeys(null, any()) } throws ScanCardException.UserCancelled + + runCatching { + repository.derivePublicKeys( + userWalletId = defaultUserWalletId, + currencies = CryptoCurrenciesMocks(userWallet.scanResponse).ethereum, + ) + } + .onSuccess { error("Should throws exception") } + .onFailure { Truth.assertThat(it).isInstanceOf(ScanCardException.UserCancelled::class.java) } + + coVerify(exactly = 1) { userWalletsStore.getSyncOrNull(defaultUserWalletId) } + coVerify(exactly = 1) { tangemSdkManager.derivePublicKeys(null, any()) } + coVerify(inverse = true) { userWalletsStore.update(defaultUserWalletId, any()) } + } + + @SuppressLint("CheckResult") + @Test + fun `success case`() = runTest { + val userWallet = defaultUserWallet.copy( + scanResponse = ScanResponseMockFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()), + ) + coEvery { userWalletsStore.getSyncOrNull(defaultUserWalletId) } returns userWallet + coEvery { tangemSdkManager.derivePublicKeys(null, any()) } returns CompletionResult.Success( + DerivationTaskResponse(DerivedKeysMocks.ethereumDerivedKeys), + ) + coEvery { userWalletsStore.update(defaultUserWalletId, any()) } just Runs + + runCatching { + repository.derivePublicKeys( + userWalletId = defaultUserWalletId, + currencies = CryptoCurrenciesMocks(userWallet.scanResponse).ethereum, + ) + } + .onSuccess { Truth.assertThat(it) } + .onFailure { error("Should returns success") } + + coVerify(exactly = 1) { userWalletsStore.getSyncOrNull(defaultUserWalletId) } + coVerify(exactly = 1) { tangemSdkManager.derivePublicKeys(null, any()) } + coVerify(exactly = 1) { userWalletsStore.update(defaultUserWalletId, any()) } + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/domain/card/DerivedKeysMocks.kt b/app/src/test/kotlin/com/tangem/tap/domain/card/DerivedKeysMocks.kt new file mode 100644 index 0000000000..a8398c930c --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/domain/card/DerivedKeysMocks.kt @@ -0,0 +1,28 @@ +package com.tangem.tap.domain.card + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.derivation.DerivationConfigV2 +import com.tangem.common.card.EllipticCurve +import com.tangem.common.extensions.toMapKey +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.operations.derivation.ExtendedPublicKeysMap + +/** +[REDACTED_AUTHOR] + */ +internal object DerivedKeysMocks { + + val ethereumDerivedKeys = mapOf( + EllipticCurve.Secp256k1.name.toByteArray().toMapKey() to ExtendedPublicKeysMap( + mapOf( + DerivationConfigV2.derivations(Blockchain.Ethereum).values.first() to ExtendedPublicKey( + publicKey = ByteArray(0), + chainCode = ByteArray(0), + depth = 2646, + parentFingerprint = ByteArray(0), + childNumber = 1142, + ), + ), + ), + ) +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/domain/card/MissedDerivationsFinderTest.kt b/app/src/test/kotlin/com/tangem/tap/domain/card/MissedDerivationsFinderTest.kt new file mode 100644 index 0000000000..2506e539a5 --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/domain/card/MissedDerivationsFinderTest.kt @@ -0,0 +1,136 @@ +package com.tangem.tap.domain.card + +import com.google.common.truth.Truth +import com.tangem.blockchain.blockchains.cardano.CardanoUtils +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.derivation.DerivationConfigV2 +import com.tangem.common.card.EllipticCurve +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.common.configs.GenericCardConfig +import com.tangem.domain.common.configs.MultiWalletCardConfig +import com.tangem.domain.common.configs.Wallet2CardConfig +import com.tangem.domain.common.util.derivationStyleProvider +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class MissedDerivationsFinderTest { + + @Test + fun `empty derivations for empty currencies`() { + val scanResponse = ScanResponseMockFactory.create(cardConfig = GenericCardConfig, derivedKeys = emptyMap()) + val finder = MissedDerivationsFinder(scanResponse) + + val actual = finder.find(emptyList()) + + Truth.assertThat(actual).isEmpty() + } + + @Test + fun `empty derivations for non supported blockchains`() { + // Bls is not supported + val scanResponse = ScanResponseMockFactory.create(cardConfig = GenericCardConfig, derivedKeys = emptyMap()) + val finder = MissedDerivationsFinder(scanResponse) + + val currencies = CryptoCurrenciesMocks(scanResponse).chia + val actual = finder.find(currencies) + + Truth.assertThat(actual).isEmpty() + } + + @Test + fun `derivations ONLY for supported blockchains`() { + // Bls is not supported + val scanResponse = ScanResponseMockFactory.create( + cardConfig = GenericCardConfig, + derivedKeys = emptyMap(), + ).let { + it.copy( + card = it.card.copy( + settings = it.card.settings.copy(isHDWalletAllowed = true, isBackupAllowed = true), + ), + ) + } + val finder = MissedDerivationsFinder(scanResponse) + + val currencies = CryptoCurrenciesMocks(scanResponse).chiaAndEthereum + val actual = finder.find(currencies) + + Truth.assertThat(actual).containsExactly( + ByteArrayKey(EllipticCurve.Secp256k1.name.toByteArray()), + listOf(DerivationConfigV2.derivations(Blockchain.Ethereum).values.first()), + ) + } + + @Test + fun `derivations for custom token`() { + val scanResponse = ScanResponseMockFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()) + val finder = MissedDerivationsFinder(scanResponse) + + val currencies = CryptoCurrenciesMocks(scanResponse).ethereumTokenWithBinanceDerivation + val actual = finder.find(currencies) + + Truth.assertThat(actual).containsExactly( + ByteArrayKey(EllipticCurve.Secp256k1.name.toByteArray()), + listOf( + DerivationConfigV2.derivations(Blockchain.Ethereum).values.first(), + DerivationConfigV2.derivations(Blockchain.Binance).values.first(), + ), + ) + } + + @Test + fun `derivations for cardano`() { + val scanResponse = ScanResponseMockFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()) + val finder = MissedDerivationsFinder(scanResponse) + + val currencies = CryptoCurrenciesMocks(scanResponse).cardano + val actual = finder.find(currencies) + + Truth.assertThat(actual).containsExactly( + ByteArrayKey(EllipticCurve.Ed25519.name.toByteArray()), + listOf( + DerivationConfigV2.derivations(Blockchain.Cardano).values.first(), + CardanoUtils.extendedDerivationPath( + derivationPath = DerivationPath( + Blockchain.Cardano.derivationPath(scanResponse.derivationStyleProvider.getDerivationStyle())!! + .rawPath, + ), + ), + ), + ) + } + + @Test + fun `empty derivations for already derived currencies`() { + val scanResponse = ScanResponseMockFactory.create( + cardConfig = Wallet2CardConfig, + derivedKeys = DerivedKeysMocks.ethereumDerivedKeys, + ) + val finder = MissedDerivationsFinder(scanResponse) + + val currencies = CryptoCurrenciesMocks(scanResponse).ethereum + val actual = finder.find(currencies) + + Truth.assertThat(actual).isEmpty() + } + + @Test + fun `derivations ONLY for never derived currencies`() { + val scanResponse = ScanResponseMockFactory.create( + cardConfig = MultiWalletCardConfig, + derivedKeys = DerivedKeysMocks.ethereumDerivedKeys, + ) + val finder = MissedDerivationsFinder(scanResponse) + + val currencies = CryptoCurrenciesMocks(scanResponse).ethereumAndStellar + val actual = finder.find(currencies) + + Truth.assertThat(actual).containsExactly( + ByteArrayKey(EllipticCurve.Ed25519.name.toByteArray()), + listOf(DerivationConfigV2.derivations(Blockchain.Stellar).values.first()), + ) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/domain/card/ScanResponseMockFactory.kt b/app/src/test/kotlin/com/tangem/tap/domain/card/ScanResponseMockFactory.kt new file mode 100644 index 0000000000..359cde99df --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/domain/card/ScanResponseMockFactory.kt @@ -0,0 +1,109 @@ +package com.tangem.tap.domain.card + +import com.tangem.common.card.CardWallet +import com.tangem.common.card.FirmwareVersion +import com.tangem.domain.common.configs.CardConfig +import com.tangem.domain.common.configs.GenericCardConfig +import com.tangem.domain.common.configs.MultiWalletCardConfig +import com.tangem.domain.common.configs.Wallet2CardConfig +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.KeyWalletPublicKey +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import java.util.Date + +/** +[REDACTED_AUTHOR] + */ +object ScanResponseMockFactory { + + private val genericFirmwareVersion = CardDTO.FirmwareVersion( + major = 3, + minor = 29, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ) + + private val walletFirmwareVersion = CardDTO.FirmwareVersion( + major = 4, + minor = 0, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ) + + private val wallet2FirmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ) + + fun create(cardConfig: CardConfig, derivedKeys: Map): ScanResponse { + return ScanResponse( + card = CardDTO( + cardId = "NEVER-MIND", + batchId = "NEVER-MIND", + cardPublicKey = ByteArray(0), + firmwareVersion = when (cardConfig) { + GenericCardConfig -> genericFirmwareVersion + MultiWalletCardConfig -> walletFirmwareVersion + Wallet2CardConfig -> wallet2FirmwareVersion + }, + manufacturer = CardDTO.Manufacturer(name = "NEVER-MIND", manufactureDate = Date(), signature = null), + issuer = CardDTO.Issuer(name = "NEVER-MIND", publicKey = ByteArray(0)), + settings = CardDTO.Settings( + securityDelay = 0, + maxWalletsCount = 0, + isSettingAccessCodeAllowed = false, + isSettingPasscodeAllowed = false, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = false, + isBackupAllowed = cardConfig is MultiWalletCardConfig, + supportedEncryptionModes = listOf(), + isFilesAllowed = true, + isHDWalletAllowed = cardConfig is MultiWalletCardConfig, + isKeysImportAllowed = true, + ), + userSettings = null, + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.Current, + isAccessCodeSet = false, + isPasscodeSet = null, + supportedCurves = listOf(), + wallets = cardConfig.mandatoryCurves.map { + CardDTO.Wallet( + CardWallet( + publicKey = it.name.toByteArray(), // IMPORTANT: public key must equal to curve name + chainCode = null, + curve = it, + settings = createSettings(), + totalSignedHashes = null, + remainingSignatures = null, + index = 0, + isImported = true, + hasBackup = true, + derivedKeys = mapOf(), + ), + ) + }, + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Skipped, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = null, + ), + productType = ProductType.Wallet2, + walletData = null, + derivedKeys = derivedKeys, + ) + } + + private fun createSettings(): CardWallet.Settings { + val constructor = CardWallet.Settings::class.java.declaredConstructors[0] + constructor.isAccessible = true + return constructor.newInstance(false) as CardWallet.Settings + } +} \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/DerivePublicKeysUseCase.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/DerivePublicKeysUseCase.kt index d810031075..8aad6ceb32 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/DerivePublicKeysUseCase.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/DerivePublicKeysUseCase.kt @@ -7,9 +7,10 @@ import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId import com.tangem.operations.derivation.DerivationTaskResponse -// FIXME: Cover with tests [REDACTED_JIRA] +// TODO: Convert to class [REDACTED_JIRA] interface DerivePublicKeysUseCase { + // TODO: delete [REDACTED_JIRA] @Deprecated(message = "Use invoke(cardId: String?, derivations: Map>) instead") suspend operator fun invoke( cardId: String? = null,