Add comprehensive tests for Unlock feature (#6426)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Honkonen
2026-02-03 09:33:58 -05:00
committed by GitHub
parent a2ec99fb05
commit 4cac4d6a6e
2 changed files with 774 additions and 0 deletions

View File

@@ -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,
)

View File

@@ -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,
)
}