diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreenTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreenTest.kt new file mode 100644 index 0000000000..a7c0a7dc22 --- /dev/null +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreenTest.kt @@ -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() + + 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, +) diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModelTest.kt new file mode 100644 index 0000000000..3f89613b50 --- /dev/null +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModelTest.kt @@ -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, + ) +}