mirror of
https://github.com/bitwarden/android.git
synced 2026-03-11 20:54:58 -05:00
Add comprehensive tests for Unlock feature (#6426)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
package com.bitwarden.authenticator.ui.auth.unlock
|
||||
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.authenticator.ui.platform.base.AuthenticatorComposeTest
|
||||
import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.asText
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import javax.crypto.Cipher
|
||||
|
||||
class UnlockScreenTest : AuthenticatorComposeTest() {
|
||||
|
||||
private var onUnlockedCalled = false
|
||||
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<UnlockEvent>()
|
||||
|
||||
private val mockViewModel: UnlockViewModel = mockk(relaxed = true) {
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { trySendAction(any()) } just runs
|
||||
}
|
||||
|
||||
private val mockBiometricsManager: BiometricsManager = mockk(relaxed = true)
|
||||
private val mockCipher: Cipher = mockk()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
setContent(
|
||||
biometricsManager = mockBiometricsManager,
|
||||
) {
|
||||
UnlockScreen(
|
||||
viewModel = mockViewModel,
|
||||
onUnlocked = { onUnlockedCalled = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unlock button should be displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Unlock")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `logo should be displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Bitwarden Authenticator")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unlock button click should send BiometricsUnlockClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Unlock")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
mockViewModel.trySendAction(UnlockAction.BiometricsUnlockClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToItemListing event should call onUnlocked callback`() {
|
||||
mutableEventFlow.tryEmit(UnlockEvent.NavigateToItemListing)
|
||||
|
||||
assertTrue(onUnlockedCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PromptForBiometrics event should call biometricsManager`() {
|
||||
val onSuccessSlot = slot<(Cipher) -> Unit>()
|
||||
val onCancelSlot = slot<() -> Unit>()
|
||||
val onErrorSlot = slot<() -> Unit>()
|
||||
val onLockOutSlot = slot<() -> Unit>()
|
||||
|
||||
every {
|
||||
mockBiometricsManager.promptBiometrics(
|
||||
onSuccess = capture(onSuccessSlot),
|
||||
onCancel = capture(onCancelSlot),
|
||||
onError = capture(onErrorSlot),
|
||||
onLockOut = capture(onLockOutSlot),
|
||||
cipher = mockCipher,
|
||||
)
|
||||
} just runs
|
||||
|
||||
mutableEventFlow.tryEmit(UnlockEvent.PromptForBiometrics(mockCipher))
|
||||
|
||||
verify {
|
||||
mockBiometricsManager.promptBiometrics(
|
||||
onSuccess = any(),
|
||||
onCancel = any(),
|
||||
onError = any(),
|
||||
onLockOut = any(),
|
||||
cipher = mockCipher,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `biometric success callback should send BiometricsUnlockSuccess action`() {
|
||||
val onSuccessSlot = slot<(Cipher) -> Unit>()
|
||||
|
||||
every {
|
||||
mockBiometricsManager.promptBiometrics(
|
||||
onSuccess = capture(onSuccessSlot),
|
||||
onCancel = any(),
|
||||
onError = any(),
|
||||
onLockOut = any(),
|
||||
cipher = mockCipher,
|
||||
)
|
||||
} just runs
|
||||
|
||||
mutableEventFlow.tryEmit(UnlockEvent.PromptForBiometrics(mockCipher))
|
||||
|
||||
// Invoke the captured success callback
|
||||
onSuccessSlot.captured.invoke(mockCipher)
|
||||
|
||||
verify {
|
||||
mockViewModel.trySendAction(UnlockAction.BiometricsUnlockSuccess(mockCipher))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `biometric lockout callback should send BiometricsLockout action`() {
|
||||
val onLockOutSlot = slot<() -> Unit>()
|
||||
|
||||
every {
|
||||
mockBiometricsManager.promptBiometrics(
|
||||
onSuccess = any(),
|
||||
onCancel = any(),
|
||||
onError = any(),
|
||||
onLockOut = capture(onLockOutSlot),
|
||||
cipher = mockCipher,
|
||||
)
|
||||
} just runs
|
||||
|
||||
mutableEventFlow.tryEmit(UnlockEvent.PromptForBiometrics(mockCipher))
|
||||
|
||||
// Invoke the captured lockout callback
|
||||
onLockOutSlot.captured.invoke()
|
||||
|
||||
verify {
|
||||
mockViewModel.trySendAction(UnlockAction.BiometricsLockout)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog should display when state has Error dialog`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
dialog = UnlockState.Dialog.Error(
|
||||
title = "Error Title".asText(),
|
||||
message = "Error Message".asText(),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Error Title")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Error Message")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog dismiss should send DismissDialog action`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
dialog = UnlockState.Dialog.Error(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTag("AcceptAlertButton")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
mockViewModel.trySendAction(UnlockAction.DismissDialog)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading dialog should display when state has Loading dialog`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(
|
||||
dialog = UnlockState.Dialog.Loading,
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Loading")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no dialog should be displayed when state dialog is null`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(dialog = null)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Loading")
|
||||
.assertDoesNotExist()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Ok")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `biometric cancel callback should not crash`() {
|
||||
val onCancelSlot = slot<() -> Unit>()
|
||||
|
||||
every {
|
||||
mockBiometricsManager.promptBiometrics(
|
||||
onSuccess = any(),
|
||||
onCancel = capture(onCancelSlot),
|
||||
onError = any(),
|
||||
onLockOut = any(),
|
||||
cipher = mockCipher,
|
||||
)
|
||||
} just runs
|
||||
|
||||
mutableEventFlow.tryEmit(UnlockEvent.PromptForBiometrics(mockCipher))
|
||||
|
||||
// Invoke the captured cancel callback - should not crash
|
||||
onCancelSlot.captured.invoke()
|
||||
|
||||
// Verify no action was sent (it's a no-op)
|
||||
verify(exactly = 0) {
|
||||
mockViewModel.trySendAction(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `biometric error callback should not crash`() {
|
||||
val onErrorSlot = slot<() -> Unit>()
|
||||
|
||||
every {
|
||||
mockBiometricsManager.promptBiometrics(
|
||||
onSuccess = any(),
|
||||
onCancel = any(),
|
||||
onError = capture(onErrorSlot),
|
||||
onLockOut = any(),
|
||||
cipher = mockCipher,
|
||||
)
|
||||
} just runs
|
||||
|
||||
mutableEventFlow.tryEmit(UnlockEvent.PromptForBiometrics(mockCipher))
|
||||
|
||||
// Invoke the captured error callback - should not crash
|
||||
onErrorSlot.captured.invoke()
|
||||
|
||||
// Verify no action was sent (it's a no-op)
|
||||
verify(exactly = 0) {
|
||||
mockViewModel.trySendAction(any())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE = UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = null,
|
||||
)
|
||||
@@ -0,0 +1,488 @@
|
||||
package com.bitwarden.authenticator.ui.auth.unlock
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
|
||||
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsUnlockResult
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.asText
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import javax.crypto.Cipher
|
||||
|
||||
class UnlockViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mockCipher: Cipher = mockk()
|
||||
private val mockAuthRepository: AuthRepository = mockk {
|
||||
every { isUnlockWithBiometricsEnabled } returns true
|
||||
every { isBiometricIntegrityValid() } returns true
|
||||
every { isAccountBiometricIntegrityValid() } returns true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when no saved state`() {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = null,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should restore from SavedStateHandle`() {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
|
||||
val savedState = UnlockState(
|
||||
isBiometricsEnabled = false,
|
||||
isBiometricsValid = false,
|
||||
showBiometricInvalidatedMessage = true,
|
||||
dialog = UnlockState.Dialog.Loading,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(initialState = savedState)
|
||||
|
||||
assertEquals(savedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `init should emit PromptForBiometrics when cipher available`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns mockCipher
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(UnlockEvent.PromptForBiometrics(mockCipher), awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `init should not emit event when cipher unavailable`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BiometricsUnlockClick with valid cipher should emit PromptForBiometrics`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns mockCipher
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
// Skip init event
|
||||
skipItems(1)
|
||||
|
||||
viewModel.trySendAction(UnlockAction.BiometricsUnlockClick)
|
||||
|
||||
assertEquals(UnlockEvent.PromptForBiometrics(mockCipher), awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BiometricsUnlockClick with null cipher should update state`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
every { mockAuthRepository.isAccountBiometricIntegrityValid() } returns false
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = null,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(UnlockAction.BiometricsUnlockClick)
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = false,
|
||||
showBiometricInvalidatedMessage = true,
|
||||
dialog = null,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BiometricsUnlockSuccess should show Loading dialog`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
coEvery {
|
||||
mockAuthRepository.unlockWithBiometrics(mockCipher)
|
||||
} returns BiometricsUnlockResult.Success
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = null,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(UnlockAction.BiometricsUnlockSuccess(mockCipher))
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = UnlockState.Dialog.Loading,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = null,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BiometricsUnlockSuccess should call repository unlockWithBiometrics`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
coEvery {
|
||||
mockAuthRepository.unlockWithBiometrics(mockCipher)
|
||||
} returns BiometricsUnlockResult.Success
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(UnlockAction.BiometricsUnlockSuccess(mockCipher))
|
||||
|
||||
// Allow coroutine to complete
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
coVerify { mockAuthRepository.unlockWithBiometrics(mockCipher) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success result should navigate to item listing`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
coEvery {
|
||||
mockAuthRepository.unlockWithBiometrics(mockCipher)
|
||||
} returns BiometricsUnlockResult.Success
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents()
|
||||
|
||||
viewModel.trySendAction(UnlockAction.BiometricsUnlockSuccess(mockCipher))
|
||||
|
||||
assertEquals(UnlockEvent.NavigateToItemListing, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BiometricDecodingError should clear biometrics`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
every { mockAuthRepository.clearBiometrics() } just runs
|
||||
coEvery {
|
||||
mockAuthRepository.unlockWithBiometrics(mockCipher)
|
||||
} returns BiometricsUnlockResult.BiometricDecodingError(null)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = null,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(UnlockAction.BiometricsUnlockSuccess(mockCipher))
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = UnlockState.Dialog.Loading,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = false,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = UnlockState.Dialog.Error(
|
||||
title = BitwardenString.biometrics_failed.asText(),
|
||||
message = BitwardenString.biometrics_decoding_failure.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
|
||||
verify { mockAuthRepository.clearBiometrics() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `InvalidStateError should show error dialog`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
val testError = IllegalStateException("Test error")
|
||||
coEvery {
|
||||
mockAuthRepository.unlockWithBiometrics(mockCipher)
|
||||
} returns BiometricsUnlockResult.InvalidStateError(testError)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = null,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(UnlockAction.BiometricsUnlockSuccess(mockCipher))
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = UnlockState.Dialog.Loading,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = UnlockState.Dialog.Error(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
throwable = testError,
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BiometricsLockout should show error dialog`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = null,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(UnlockAction.BiometricsLockout)
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = UnlockState.Dialog.Error(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.too_many_failed_biometric_attempts.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DismissDialog should clear dialog state`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// First set a dialog
|
||||
viewModel.trySendAction(UnlockAction.BiometricsLockout)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = UnlockState.Dialog.Error(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.too_many_failed_biometric_attempts.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(UnlockAction.DismissDialog)
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = null,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ReceiveVaultUnlockResult with Success should navigate and clear dialog`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
coEvery {
|
||||
mockAuthRepository.unlockWithBiometrics(mockCipher)
|
||||
} returns BiometricsUnlockResult.Success
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = null,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(UnlockAction.BiometricsUnlockSuccess(mockCipher))
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = UnlockState.Dialog.Loading,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = null,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ReceiveVaultUnlockResult with BiometricDecodingError should update state`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
every { mockAuthRepository.clearBiometrics() } just runs
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(
|
||||
UnlockAction.Internal.ReceiveVaultUnlockResult(
|
||||
BiometricsUnlockResult.BiometricDecodingError(null),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = false,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = UnlockState.Dialog.Error(
|
||||
title = BitwardenString.biometrics_failed.asText(),
|
||||
message = BitwardenString.biometrics_decoding_failure.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ReceiveVaultUnlockResult with InvalidStateError should show error`() = runTest {
|
||||
every { mockAuthRepository.getOrCreateCipher() } returns null
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val testError = RuntimeException("Invalid state")
|
||||
|
||||
viewModel.trySendAction(
|
||||
UnlockAction.Internal.ReceiveVaultUnlockResult(
|
||||
BiometricsUnlockResult.InvalidStateError(testError),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
UnlockState(
|
||||
isBiometricsEnabled = true,
|
||||
isBiometricsValid = true,
|
||||
showBiometricInvalidatedMessage = false,
|
||||
dialog = UnlockState.Dialog.Error(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
throwable = testError,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
initialState: UnlockState? = null,
|
||||
): UnlockViewModel = UnlockViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
|
||||
authRepository = mockAuthRepository,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user