Updated on 2026-08-14
This commit is contained in:
parent
c8ded3b7d5
commit
bff165de00
7 changed files with 253 additions and 54 deletions
|
|
@ -496,10 +496,10 @@ internal class DefaultUserWalletsListRepository(
|
||||||
is HotWalletPasswordRequester.Result.EnteredPassword -> {
|
is HotWalletPasswordRequester.Result.EnteredPassword -> {
|
||||||
val decrypted = block(result.password.value)
|
val decrypted = block(result.password.value)
|
||||||
if (decrypted == null) {
|
if (decrypted == null) {
|
||||||
passwordRequester.wrongPassword()
|
passwordRequester.wrongPassword(attemptRequest)
|
||||||
requestPasswordRecursive(hotWalletId, block, biometryFallback)
|
requestPasswordRecursive(hotWalletId, block, biometryFallback)
|
||||||
} else {
|
} else {
|
||||||
passwordRequester.successfulAuthentication()
|
passwordRequester.successfulAuthentication(attemptRequest)
|
||||||
passwordRequester.dismiss()
|
passwordRequester.dismiss()
|
||||||
decrypted.right()
|
decrypted.right()
|
||||||
}
|
}
|
||||||
|
|
@ -507,7 +507,7 @@ internal class DefaultUserWalletsListRepository(
|
||||||
HotWalletPasswordRequester.Result.UseBiometry -> {
|
HotWalletPasswordRequester.Result.UseBiometry -> {
|
||||||
biometryFallback()
|
biometryFallback()
|
||||||
.onRight {
|
.onRight {
|
||||||
passwordRequester.successfulAuthentication()
|
passwordRequester.successfulAuthentication(attemptRequest)
|
||||||
passwordRequester.dismiss()
|
passwordRequester.dismiss()
|
||||||
}
|
}
|
||||||
.map { null }
|
.map { null }
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@ class DefaultHotWalletAccessor @Inject constructor(
|
||||||
private val scope: AppCoroutineScope,
|
private val scope: AppCoroutineScope,
|
||||||
) : HotWalletAccessor {
|
) : HotWalletAccessor {
|
||||||
|
|
||||||
|
private data class RequestedAuth(
|
||||||
|
val auth: HotAuth,
|
||||||
|
val attemptRequest: HotWalletPasswordRequester.AttemptRequest?,
|
||||||
|
)
|
||||||
|
|
||||||
private val contextualUnlockHotWallet: ConcurrentHashMap<HotWalletId, UnlockHotWallet?> = ConcurrentHashMap()
|
private val contextualUnlockHotWallet: ConcurrentHashMap<HotWalletId, UnlockHotWallet?> = ConcurrentHashMap()
|
||||||
|
|
||||||
override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
|
override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
|
||||||
|
|
@ -84,8 +89,8 @@ class DefaultHotWalletAccessor @Inject constructor(
|
||||||
private suspend fun <T> hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T {
|
private suspend fun <T> hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T {
|
||||||
val isAccessCodeRequired = isAccessCodeRequired()
|
val isAccessCodeRequired = isAccessCodeRequired()
|
||||||
|
|
||||||
val auth = when (hotWalletId.authType) {
|
val requestedAuth = when (hotWalletId.authType) {
|
||||||
HotWalletId.AuthType.NoPassword -> HotAuth.NoAuth
|
HotWalletId.AuthType.NoPassword -> RequestedAuth(HotAuth.NoAuth, attemptRequest = null)
|
||||||
HotWalletId.AuthType.Password -> requestPassword(
|
HotWalletId.AuthType.Password -> requestPassword(
|
||||||
hotWalletId = hotWalletId,
|
hotWalletId = hotWalletId,
|
||||||
hasBiometry = false,
|
hasBiometry = false,
|
||||||
|
|
@ -97,35 +102,32 @@ class DefaultHotWalletAccessor @Inject constructor(
|
||||||
hasBiometry = false,
|
hasBiometry = false,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
HotAuth.Biometry
|
RequestedAuth(HotAuth.Biometry, attemptRequest = null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return runCatchingSdkErrors(hotWalletId, auth) {
|
return runCatchingSdkErrors(hotWalletId, requestedAuth) {
|
||||||
block(UnlockHotWallet(hotWalletId, it)).also {
|
block(UnlockHotWallet(hotWalletId, it))
|
||||||
hotWalletPasswordRequester.successfulAuthentication()
|
|
||||||
hotWalletPasswordRequester.dismiss()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun <T> runCatchingSdkErrors(
|
private suspend fun <T> runCatchingSdkErrors(
|
||||||
hotWalletId: HotWalletId,
|
hotWalletId: HotWalletId,
|
||||||
auth: HotAuth,
|
requestedAuth: RequestedAuth,
|
||||||
block: suspend (auth: HotAuth) -> T,
|
block: suspend (auth: HotAuth) -> T,
|
||||||
): T {
|
): T {
|
||||||
return runCatchingWrongPassInternal(
|
return runCatchingWrongPassInternal(
|
||||||
hotWalletId = hotWalletId,
|
hotWalletId = hotWalletId,
|
||||||
originalAuth = auth,
|
originalAuth = requestedAuth,
|
||||||
auth = auth,
|
requestedAuth = requestedAuth,
|
||||||
block = { blockAuth ->
|
block = { blockAuth ->
|
||||||
val result = block(blockAuth)
|
val result = block(blockAuth)
|
||||||
|
|
||||||
// Update biometry auth if the original auth was password
|
// Update biometry auth if the original auth was password
|
||||||
updateBiometryAuthIfNeeded(
|
updateBiometryAuthIfNeeded(
|
||||||
hotWalletId = hotWalletId,
|
hotWalletId = hotWalletId,
|
||||||
originalAuth = auth,
|
originalAuth = requestedAuth.auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
result
|
result
|
||||||
|
|
@ -161,13 +163,19 @@ class DefaultHotWalletAccessor @Inject constructor(
|
||||||
|
|
||||||
private suspend fun <T> runCatchingWrongPassInternal(
|
private suspend fun <T> runCatchingWrongPassInternal(
|
||||||
hotWalletId: HotWalletId,
|
hotWalletId: HotWalletId,
|
||||||
originalAuth: HotAuth,
|
originalAuth: RequestedAuth,
|
||||||
auth: HotAuth,
|
requestedAuth: RequestedAuth,
|
||||||
block: suspend (auth: HotAuth) -> T,
|
block: suspend (auth: HotAuth) -> T,
|
||||||
): T = runSuspendCatching {
|
): T = runSuspendCatching {
|
||||||
block(auth)
|
block(requestedAuth.auth).also {
|
||||||
|
val request = requestedAuth.attemptRequest
|
||||||
|
if (request != null) {
|
||||||
|
hotWalletPasswordRequester.successfulAuthentication(request)
|
||||||
|
}
|
||||||
|
hotWalletPasswordRequester.dismiss()
|
||||||
|
}
|
||||||
}.getOrElse { exception ->
|
}.getOrElse { exception ->
|
||||||
if (auth is HotAuth.Biometry && (exception.isBiometryError() || exception.isBiometryReset())) {
|
if (requestedAuth.auth is HotAuth.Biometry && (exception.isBiometryError() || exception.isBiometryReset())) {
|
||||||
val shouldRetryBiometry = exception is TangemSdkError.AuthenticationCanceled
|
val shouldRetryBiometry = exception is TangemSdkError.AuthenticationCanceled
|
||||||
|
|
||||||
// fallback to password if biometry fails
|
// fallback to password if biometry fails
|
||||||
|
|
@ -179,7 +187,7 @@ class DefaultHotWalletAccessor @Inject constructor(
|
||||||
return@getOrElse runCatchingWrongPassInternal(
|
return@getOrElse runCatchingWrongPassInternal(
|
||||||
hotWalletId = hotWalletId,
|
hotWalletId = hotWalletId,
|
||||||
originalAuth = originalAuth,
|
originalAuth = originalAuth,
|
||||||
auth = passAuth,
|
requestedAuth = passAuth,
|
||||||
block = block,
|
block = block,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -190,29 +198,30 @@ class DefaultHotWalletAccessor @Inject constructor(
|
||||||
|
|
||||||
// If the exception is a wrong password, we need to request the password again
|
// If the exception is a wrong password, we need to request the password again
|
||||||
|
|
||||||
hotWalletPasswordRequester.wrongPassword()
|
requestedAuth.attemptRequest?.let { hotWalletPasswordRequester.wrongPassword(it) }
|
||||||
val passResult = requestPassword(
|
val passResult = requestPassword(
|
||||||
hotWalletId = hotWalletId,
|
hotWalletId = hotWalletId,
|
||||||
hasBiometry = originalAuth is HotAuth.Biometry,
|
hasBiometry = originalAuth.auth is HotAuth.Biometry,
|
||||||
)
|
)
|
||||||
|
|
||||||
runCatchingWrongPassInternal(
|
runCatchingWrongPassInternal(
|
||||||
hotWalletId = hotWalletId,
|
hotWalletId = hotWalletId,
|
||||||
originalAuth = originalAuth,
|
originalAuth = originalAuth,
|
||||||
auth = passResult,
|
requestedAuth = passResult,
|
||||||
block = block,
|
block = block,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun requestPassword(hotWalletId: HotWalletId, hasBiometry: Boolean): HotAuth {
|
private suspend fun requestPassword(hotWalletId: HotWalletId, hasBiometry: Boolean): RequestedAuth {
|
||||||
val attemptRequest = HotWalletPasswordRequester.AttemptRequest(
|
val attemptRequest = HotWalletPasswordRequester.AttemptRequest(
|
||||||
hotWalletId = hotWalletId,
|
hotWalletId = hotWalletId,
|
||||||
authMode = false,
|
authMode = false,
|
||||||
hasBiometry = hasBiometry,
|
hasBiometry = hasBiometry,
|
||||||
)
|
)
|
||||||
|
|
||||||
return hotWalletPasswordRequester.requestPassword(attemptRequest).toAuth()
|
val auth = hotWalletPasswordRequester.requestPassword(attemptRequest).toAuth()
|
||||||
?: throw TangemSdkError.UserCancelled()
|
?: throw TangemSdkError.UserCancelled()
|
||||||
|
return RequestedAuth(auth, attemptRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun isAccessCodeRequired(): Boolean {
|
private suspend fun isAccessCodeRequired(): Boolean {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.tangem.domain.wallets.hot
|
||||||
|
|
||||||
import com.tangem.hot.sdk.model.HotAuth
|
import com.tangem.hot.sdk.model.HotAuth
|
||||||
import com.tangem.hot.sdk.model.HotWalletId
|
import com.tangem.hot.sdk.model.HotWalletId
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for requesting the password for a hot wallet.
|
* Interface for requesting the password for a hot wallet.
|
||||||
|
|
@ -11,13 +12,19 @@ interface HotWalletPasswordRequester {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets state to show wrong password state.
|
* Sets state to show wrong password state.
|
||||||
|
*
|
||||||
|
* The result is attributed to [attemptRequest] (the request that produced it), not to whatever
|
||||||
|
* request currently owns the dialog. This prevents a late callback of one request from
|
||||||
|
* incrementing the failed-attempt counter of a different wallet that meanwhile replaced it.
|
||||||
*/
|
*/
|
||||||
suspend fun wrongPassword()
|
suspend fun wrongPassword(attemptRequest: AttemptRequest)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets state to show successful authentication state.
|
* Sets state to show successful authentication state.
|
||||||
|
*
|
||||||
|
* Attributed to [attemptRequest], see [wrongPassword].
|
||||||
*/
|
*/
|
||||||
suspend fun successfulAuthentication()
|
suspend fun successfulAuthentication(attemptRequest: AttemptRequest)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests the user to enter the password for the hot wallet.
|
* Requests the user to enter the password for the hot wallet.
|
||||||
|
|
@ -38,11 +45,14 @@ interface HotWalletPasswordRequester {
|
||||||
* In auth mode user can be deleted after failed attempts.
|
* In auth mode user can be deleted after failed attempts.
|
||||||
* @param hasBiometry Indicates whether to show biometric authentication option to the user.
|
* @param hasBiometry Indicates whether to show biometric authentication option to the user.
|
||||||
* Will be ignored if the device does not support biometry at the moment of the request.
|
* Will be ignored if the device does not support biometry at the moment of the request.
|
||||||
|
* @param requestId Unique identity of this request, used to bind async result callbacks
|
||||||
|
* (wrong/successful) back to the exact request that produced them.
|
||||||
*/
|
*/
|
||||||
data class AttemptRequest(
|
data class AttemptRequest(
|
||||||
val hotWalletId: HotWalletId,
|
val hotWalletId: HotWalletId,
|
||||||
val authMode: Boolean,
|
val authMode: Boolean,
|
||||||
val hasBiometry: Boolean,
|
val hasBiometry: Boolean,
|
||||||
|
val requestId: String = UUID.randomUUID().toString(),
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class Result {
|
sealed class Result {
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,12 @@ internal class DefaultHotAccessCodeRequestComponent @AssistedInject constructor(
|
||||||
|
|
||||||
private val model: HotAccessCodeRequestModel = getOrCreateModel(params)
|
private val model: HotAccessCodeRequestModel = getOrCreateModel(params)
|
||||||
|
|
||||||
override suspend fun wrongPassword() {
|
override suspend fun wrongPassword(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
|
||||||
model.wrongAccessCode()
|
model.wrongAccessCode(attemptRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun successfulAuthentication() {
|
override suspend fun successfulAuthentication(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
|
||||||
model.successfulAuthentication()
|
model.successfulAuthentication(attemptRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun requestPassword(
|
override suspend fun requestPassword(
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ internal class HotAccessCodeRequestModel @Inject constructor(
|
||||||
|
|
||||||
currentRequest.value = attemptRequest
|
currentRequest.value = attemptRequest
|
||||||
result.value = null // Reset the result when showing the dialog
|
result.value = null // Reset the result when showing the dialog
|
||||||
subscribeToAttempts(id = attemptRequest.attemptId)
|
subscribeToAttempts(attemptRequest)
|
||||||
uiState.update {
|
uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isShown = true,
|
isShown = true,
|
||||||
|
|
@ -88,31 +88,37 @@ internal class HotAccessCodeRequestModel @Inject constructor(
|
||||||
dismissState()
|
dismissState()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun wrongAccessCode() {
|
suspend fun wrongAccessCode(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
|
||||||
val currentRequest = currentRequest.value ?: return
|
hotAccessCodeAttemptsRepository.incrementAttempts(attemptRequest.attemptId)
|
||||||
hotAccessCodeAttemptsRepository.incrementAttempts(currentRequest.attemptId)
|
// Reflect the wrong-code UI only if this request still owns the visible dialog.
|
||||||
uiState.update {
|
if (isCurrentRequest(attemptRequest)) {
|
||||||
it.copy(
|
uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
accessCodeColor = PinTextColor.WrongCode,
|
accessCodeColor = PinTextColor.WrongCode,
|
||||||
onAccessCodeChange = {},
|
onAccessCodeChange = {},
|
||||||
useBiometricVisible = currentRequest.isBiometryButtonVisible(),
|
useBiometricVisible = attemptRequest.isBiometryButtonVisible(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
delay(timeMillis = 500) // Delay to show the wrong access code state
|
delay(timeMillis = 500) // Delay to show the wrong access code state
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun successfulAuthentication() {
|
suspend fun successfulAuthentication(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
|
||||||
val currentRequest = currentRequest.value ?: return
|
hotAccessCodeAttemptsRepository.resetAttempts(attemptRequest.hotWalletId)
|
||||||
hotAccessCodeAttemptsRepository.resetAttempts(currentRequest.hotWalletId)
|
if (isCurrentRequest(attemptRequest)) {
|
||||||
uiState.update {
|
uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
accessCodeColor = PinTextColor.Success,
|
accessCodeColor = PinTextColor.Success,
|
||||||
onAccessCodeChange = {},
|
onAccessCodeChange = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
delay(timeMillis = 200) // Delay to show the success state
|
delay(timeMillis = 200) // Delay to show the success state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isCurrentRequest(attemptRequest: HotWalletPasswordRequester.AttemptRequest): Boolean =
|
||||||
|
currentRequest.value?.requestId == attemptRequest.requestId
|
||||||
|
|
||||||
private fun getInitialState() = HotAccessCodeRequestUM(
|
private fun getInitialState() = HotAccessCodeRequestUM(
|
||||||
onDismiss = ::dismiss,
|
onDismiss = ::dismiss,
|
||||||
onAccessCodeChange = ::onAccessCodeChange,
|
onAccessCodeChange = ::onAccessCodeChange,
|
||||||
|
|
@ -146,7 +152,8 @@ internal class HotAccessCodeRequestModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun subscribeToAttempts(id: HotWalletAccessCodeAttemptsRepository.AttemptId) {
|
private fun subscribeToAttempts(attemptRequest: HotWalletPasswordRequester.AttemptRequest) {
|
||||||
|
val id = attemptRequest.attemptId
|
||||||
fun remainingSecondsToText(remainingSeconds: Int): TextReference? {
|
fun remainingSecondsToText(remainingSeconds: Int): TextReference? {
|
||||||
return if (remainingSeconds > 0) {
|
return if (remainingSeconds > 0) {
|
||||||
resourceReference(
|
resourceReference(
|
||||||
|
|
@ -202,7 +209,7 @@ internal class HotAccessCodeRequestModel @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Attempts.Deletion -> deleteUserWallet()
|
Attempts.Deletion -> deleteUserWallet(attemptRequest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,8 +224,10 @@ internal class HotAccessCodeRequestModel @Inject constructor(
|
||||||
.any { it is UserWallet.Hot && it.hotWalletId == id }
|
.any { it is UserWallet.Hot && it.hotWalletId == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun deleteUserWallet() {
|
private suspend fun deleteUserWallet(expectedRequest: HotWalletPasswordRequester.AttemptRequest) {
|
||||||
val currentRequest = currentRequest.value ?: return
|
val currentRequest = currentRequest.value ?: return
|
||||||
|
// Only delete if the request whose threshold was crossed is still the one owning the dialog.
|
||||||
|
if (currentRequest.requestId != expectedRequest.requestId) return
|
||||||
val userWallet = userWalletsListRepository.userWalletsSync()
|
val userWallet = userWalletsListRepository.userWalletsSync()
|
||||||
.firstOrNull { it is UserWallet.Hot && it.hotWalletId == currentRequest.hotWalletId } ?: return
|
.firstOrNull { it is UserWallet.Hot && it.hotWalletId == currentRequest.hotWalletId } ?: return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@ class HotWalletPasswordRequesterProxy @Inject constructor() : HotWalletPasswordR
|
||||||
|
|
||||||
val componentRequester = MutableStateFlow<HotWalletPasswordRequester?>(null)
|
val componentRequester = MutableStateFlow<HotWalletPasswordRequester?>(null)
|
||||||
|
|
||||||
override suspend fun wrongPassword() = call { wrongPassword() }
|
override suspend fun wrongPassword(attemptRequest: HotWalletPasswordRequester.AttemptRequest) =
|
||||||
|
call { wrongPassword(attemptRequest) }
|
||||||
|
|
||||||
override suspend fun successfulAuthentication() = call { successfulAuthentication() }
|
override suspend fun successfulAuthentication(attemptRequest: HotWalletPasswordRequester.AttemptRequest) =
|
||||||
|
call { successfulAuthentication(attemptRequest) }
|
||||||
|
|
||||||
override suspend fun requestPassword(
|
override suspend fun requestPassword(
|
||||||
attemptRequest: HotWalletPasswordRequester.AttemptRequest,
|
attemptRequest: HotWalletPasswordRequester.AttemptRequest,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
package com.tangem.features.hotwallet.accesscoderequest
|
||||||
|
|
||||||
|
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||||
|
import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase
|
||||||
|
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||||
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
|
import com.tangem.domain.settings.CanUseBiometryUseCase
|
||||||
|
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
|
||||||
|
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.AttemptId
|
||||||
|
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts
|
||||||
|
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester.AttemptRequest
|
||||||
|
import com.tangem.domain.wallets.usecase.DeleteWalletUseCase
|
||||||
|
import com.tangem.features.hotwallet.HotWalletFeatureToggles
|
||||||
|
import com.tangem.hot.sdk.model.HotWalletId
|
||||||
|
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 kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
internal class HotAccessCodeRequestModelTest {
|
||||||
|
|
||||||
|
private val hotAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository = mockk(relaxed = true)
|
||||||
|
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
||||||
|
private val deleteWalletUseCase: DeleteWalletUseCase = mockk(relaxed = true)
|
||||||
|
private val canUseBiometryUseCase: CanUseBiometryUseCase = mockk(relaxed = true)
|
||||||
|
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
|
||||||
|
private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase = mockk(relaxed = true)
|
||||||
|
private val hotWalletFeatureToggles: HotWalletFeatureToggles = mockk {
|
||||||
|
every { isAssetsDiscoveryEnabled } returns false
|
||||||
|
}
|
||||||
|
|
||||||
|
private val hotWalletIdA: HotWalletId = mockk()
|
||||||
|
private val hotWalletIdB: HotWalletId = mockk()
|
||||||
|
private val walletIdA = UserWalletId("A")
|
||||||
|
private val walletIdB = UserWalletId("B")
|
||||||
|
|
||||||
|
private val userWalletA: UserWallet.Hot = mockk {
|
||||||
|
every { hotWalletId } returns hotWalletIdA
|
||||||
|
every { walletId } returns walletIdA
|
||||||
|
}
|
||||||
|
private val userWalletB: UserWallet.Hot = mockk {
|
||||||
|
every { hotWalletId } returns hotWalletIdB
|
||||||
|
every { walletId } returns walletIdB
|
||||||
|
}
|
||||||
|
|
||||||
|
private val requestA = AttemptRequest(hotWalletId = hotWalletIdA, authMode = true, hasBiometry = false)
|
||||||
|
private val requestB = AttemptRequest(hotWalletId = hotWalletIdB, authMode = true, hasBiometry = false)
|
||||||
|
|
||||||
|
private val attemptIdA = AttemptId(hotWalletId = hotWalletIdA, auth = true)
|
||||||
|
private val attemptIdB = AttemptId(hotWalletId = hotWalletIdB, auth = true)
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
clearMocks(hotAccessCodeAttemptsRepository, deleteWalletUseCase, answers = false)
|
||||||
|
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWalletA, userWalletB)
|
||||||
|
every { hotAccessCodeAttemptsRepository.getAttempts(any()) } returns emptyFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN request A replaced by B WHEN late wrong code of A THEN attempt attributed to A not B`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val model = createModel(this)
|
||||||
|
model.show(requestA)
|
||||||
|
advanceUntilIdle()
|
||||||
|
model.show(requestB) // B now owns the dialog
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
model.wrongAccessCode(requestA) // late callback belonging to A
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
coVerify(exactly = 1) { hotAccessCodeAttemptsRepository.incrementAttempts(attemptIdA) }
|
||||||
|
coVerify(exactly = 0) { hotAccessCodeAttemptsRepository.incrementAttempts(attemptIdB) }
|
||||||
|
|
||||||
|
model.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN request A replaced by B WHEN late success of A THEN reset attributed to A not B`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val model = createModel(this)
|
||||||
|
model.show(requestA)
|
||||||
|
advanceUntilIdle()
|
||||||
|
model.show(requestB)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
model.successfulAuthentication(requestA) // late callback belonging to A
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
coVerify(exactly = 1) { hotAccessCodeAttemptsRepository.resetAttempts(hotWalletIdA) }
|
||||||
|
coVerify(exactly = 0) { hotAccessCodeAttemptsRepository.resetAttempts(hotWalletIdB) }
|
||||||
|
|
||||||
|
model.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN request A owns the dialog WHEN wrong code of A THEN attempt incremented for A`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val model = createModel(this)
|
||||||
|
model.show(requestA)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
model.wrongAccessCode(requestA)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
coVerify(exactly = 1) { hotAccessCodeAttemptsRepository.incrementAttempts(attemptIdA) }
|
||||||
|
|
||||||
|
model.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN B reaches deletion threshold WHEN B owns the dialog THEN only B is deleted`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
every { hotAccessCodeAttemptsRepository.getAttempts(attemptIdB) } returns flowOf(Attempts.Deletion)
|
||||||
|
val model = createModel(this)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
model.show(requestB)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
coVerify(exactly = 1) { deleteWalletUseCase(walletIdB) }
|
||||||
|
coVerify(exactly = 0) { deleteWalletUseCase(walletIdA) }
|
||||||
|
|
||||||
|
model.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createModel(testScope: TestScope): HotAccessCodeRequestModel {
|
||||||
|
return HotAccessCodeRequestModel(
|
||||||
|
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
||||||
|
hotAccessCodeAttemptsRepository = hotAccessCodeAttemptsRepository,
|
||||||
|
userWalletsListRepository = userWalletsListRepository,
|
||||||
|
deleteWalletUseCase = deleteWalletUseCase,
|
||||||
|
canUseBiometryUseCase = canUseBiometryUseCase,
|
||||||
|
analyticsEventHandler = analyticsEventHandler,
|
||||||
|
startAssetsDiscoveryUseCase = startAssetsDiscoveryUseCase,
|
||||||
|
hotWalletFeatureToggles = hotWalletFeatureToggles,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
|
||||||
|
val testDispatcher = StandardTestDispatcher(testScheduler)
|
||||||
|
return TestingCoroutineDispatcherProvider(
|
||||||
|
main = testDispatcher,
|
||||||
|
mainImmediate = testDispatcher,
|
||||||
|
io = testDispatcher,
|
||||||
|
default = testDispatcher,
|
||||||
|
single = testDispatcher,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue