diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManager.kt index 67c686a2a..8292f00a2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManager.kt @@ -32,11 +32,20 @@ interface BiometricsManager { /** * Display a prompt for performing user verification with biometrics or device credentials. + * + * @param onSuccess Indicates the user was successfully verified. + * @param onCancel Indicates the user cancelled verification. + * @param onLockOut Indicates there were too many failed verification attempts and must wait + * some time before attempting verification again. + * @param onError Indicates the user was not verified due to an unexpected error. + * @param onNotSupported Indicates the users device is not capable of performing user + * verification. */ fun promptUserVerification( onSuccess: () -> Unit, onCancel: () -> Unit, onLockOut: () -> Unit, onError: () -> Unit, + onNotSupported: () -> Unit, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManagerImpl.kt index 42ed9e142..6e464b182 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManagerImpl.kt @@ -50,14 +50,19 @@ class BiometricsManagerImpl( onCancel: () -> Unit, onLockOut: () -> Unit, onError: () -> Unit, + onNotSupported: () -> Unit, ) { - configureAndDisplayPrompt( - onSuccess = { onSuccess() }, - onCancel = onCancel, - onLockOut = onLockOut, - onError = onError, - cipher = null, - ) + if (isUserVerificationSupported.not()) { + onNotSupported() + } else { + configureAndDisplayPrompt( + onSuccess = { onSuccess() }, + onCancel = onCancel, + onLockOut = onLockOut, + onError = onError, + cipher = null, + ) + } } private fun canAuthenticate(authenticators: Int): Boolean = diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index 559271430..6b0a6b932 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -44,11 +44,14 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager import com.x8bit.bitwarden.ui.platform.composition.LocalFido2CompletionManager import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType +import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.vault.feature.itemlisting.handlers.VaultItemListingHandlers +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.handlers.VaultItemListingUserVerificationHandlers import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType @@ -72,11 +75,15 @@ fun VaultItemListingScreen( onNavigateToSearch: (searchType: SearchType) -> Unit, intentManager: IntentManager = LocalIntentManager.current, fido2CompletionManager: Fido2CompletionManager = LocalFido2CompletionManager.current, + biometricsManager: BiometricsManager = LocalBiometricsManager.current, viewModel: VaultItemListingViewModel = hiltViewModel(), ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current val resources = context.resources + val userVerificationHandlers = remember(viewModel) { + VaultItemListingUserVerificationHandlers.create(viewModel = viewModel) + } val pullToRefreshState = rememberPullToRefreshState().takeIf { state.isPullToRefreshEnabled } LaunchedEffect(key1 = pullToRefreshState?.isRefreshing) { @@ -137,6 +144,19 @@ fun VaultItemListingScreen( is VaultItemListingEvent.CompleteFido2Registration -> { fido2CompletionManager.completeFido2Registration(event.result) } + + is VaultItemListingEvent.Fido2UserVerification -> { + biometricsManager.promptUserVerification( + onSuccess = { + userVerificationHandlers + .onUserVerificationSuccess(event.selectedCipherView) + }, + onCancel = userVerificationHandlers.onUserVerificationCancelled, + onLockOut = userVerificationHandlers.onUserVerificationLockOut, + onError = userVerificationHandlers.onUserVerificationFail, + onNotSupported = userVerificationHandlers.onUserVerificationNotSupported, + ) + } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 6c2b19f63..1fb025dcd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -4,9 +4,11 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.fido.Fido2CredentialAutofillView +import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.PublicKeyCredentialCreationOptions.AuthenticatorSelectionCriteria.UserVerificationRequirement import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult @@ -21,6 +23,7 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance +import com.x8bit.bitwarden.data.platform.manager.util.toFido2RequestOrNull import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.DataState @@ -195,6 +198,27 @@ class VaultItemListingViewModel @Inject constructor( is VaultItemListingsAction.AddVaultItemClick -> handleAddVaultItemClick() is VaultItemListingsAction.RefreshClick -> handleRefreshClick() is VaultItemListingsAction.RefreshPull -> handleRefreshPull() + + VaultItemListingsAction.UserVerificationLockOut -> { + handleUserVerificationLockOut() + } + + VaultItemListingsAction.UserVerificationCancelled -> { + handleUserVerificationCancelled() + } + + VaultItemListingsAction.UserVerificationFail -> { + handleUserVerificationFail() + } + + is VaultItemListingsAction.UserVerificationSuccess -> { + handleUserVerificationSuccess(action) + } + + VaultItemListingsAction.UserVerificationNotSupported -> { + handleUserVerificationNotSupported() + } + is VaultItemListingsAction.Internal -> handleInternalAction(action) } } @@ -230,6 +254,42 @@ class VaultItemListingViewModel @Inject constructor( vaultRepository.sync() } + private fun handleUserVerificationLockOut() { + showFido2ErrorDialog() + } + + private fun handleUserVerificationSuccess( + action: VaultItemListingsAction.UserVerificationSuccess, + ) { + specialCircumstanceManager + .specialCircumstance + ?.toFido2RequestOrNull() + ?.let { request -> + registerFido2CredentialToCipher( + request = request, + cipherView = action.selectedCipherView, + ) + } + ?: showFido2ErrorDialog() + } + + private fun handleUserVerificationFail() { + showFido2ErrorDialog() + } + + private fun handleUserVerificationCancelled() { + clearDialogState() + sendEvent( + VaultItemListingEvent.CompleteFido2Registration( + result = Fido2RegisterCredentialResult.Cancelled, + ), + ) + } + + private fun handleUserVerificationNotSupported() { + showFido2ErrorDialog() + } + private fun handleCopySendUrlClick(action: ListingItemOverflowAction.SendAction.CopyUrlClick) { clipboardManager.setText(text = action.sendUrl) } @@ -297,27 +357,7 @@ class VaultItemListingViewModel @Inject constructor( } if (state.isFido2Creation) { - val cipherView = getCipherViewOrNull(action.id) ?: return - val credentialRequest = state.fido2CredentialRequest ?: return - mutableStateFlow.update { - it.copy( - dialogState = VaultItemListingState.DialogState.Loading( - message = R.string.saving.asText(), - ), - ) - } - viewModelScope.launch { - val result = fido2CredentialManager.registerFido2Credential( - state.activeAccountSummary.userId, - fido2CredentialRequest = credentialRequest, - selectedCipherView = cipherView, - ) - sendAction( - VaultItemListingsAction.Internal.Fido2RegisterCredentialResultReceive( - result = result, - ), - ) - } + handleFido2RegistrationRequestReceive(action) return } @@ -333,6 +373,91 @@ class VaultItemListingViewModel @Inject constructor( sendEvent(event) } + private fun handleFido2RegistrationRequestReceive(action: VaultItemListingsAction.ItemClick) { + val cipherView = getCipherViewOrNull(action.id) + ?: run { + showFido2ErrorDialog() + return + } + val credentialRequest = state + .fido2CredentialRequest + ?: run { + // This scenario should not occur because `isFido2Creation` is false when + // `fido2CredentialRequest` is null. We show the FIDO 2 error dialog to inform + // the user and terminate the flow. + showFido2ErrorDialog() + return + } + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Loading( + message = R.string.saving.asText(), + ), + ) + } + val createOptions = fido2CredentialManager + .getPasskeyCreateOptionsOrNull(credentialRequest.requestJson) + ?: run { + showFido2ErrorDialog() + return + } + + when (createOptions.authenticatorSelection.userVerification) { + UserVerificationRequirement.DISCOURAGED -> { + registerFido2CredentialToCipher( + request = credentialRequest, + cipherView = cipherView, + ) + } + + UserVerificationRequirement.PREFERRED -> { + sendEvent( + VaultItemListingEvent.Fido2UserVerification( + isRequired = false, + selectedCipherView = cipherView, + ), + ) + } + + UserVerificationRequirement.REQUIRED -> { + sendEvent( + VaultItemListingEvent.Fido2UserVerification( + isRequired = true, + selectedCipherView = cipherView, + ), + ) + } + + null -> { + // Per WebAuthn spec members should be ignored when invalid. Since the request + // violates spec we display an error and terminate the operation. + showFido2ErrorDialog() + } + } + } + + private fun registerFido2CredentialToCipher( + request: Fido2CredentialRequest, + cipherView: CipherView, + ) { + val activeUserId = authRepository.activeUserId + ?: run { + showFido2ErrorDialog() + return + } + viewModelScope.launch { + val result: Fido2RegisterCredentialResult = + fido2CredentialManager.registerFido2Credential( + userId = activeUserId, + fido2CredentialRequest = request, + selectedCipherView = cipherView, + ) + sendAction( + VaultItemListingsAction.Internal.Fido2RegisterCredentialResultReceive(result), + ) + } + } + private fun handleMasterPasswordRepromptSubmit( action: VaultItemListingsAction.MasterPasswordRepromptSubmit, ) { @@ -409,6 +534,7 @@ class VaultItemListingViewModel @Inject constructor( } private fun handleDismissFido2ErrorDialogClick() { + clearDialogState() sendEvent( VaultItemListingEvent.CompleteFido2Registration( result = Fido2RegisterCredentialResult.Error, @@ -943,6 +1069,22 @@ class VaultItemListingViewModel @Inject constructor( ) as? DecryptFido2CredentialAutofillViewResult.Success) ?.fido2CredentialAutofillViews + + private fun showFido2ErrorDialog() { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_user_could_not_be_verified + .asText(), + ), + ) + } + } + + private fun clearDialogState() { + mutableStateFlow.update { it.copy(dialogState = null) } + } } /** @@ -1368,13 +1510,21 @@ sealed class VaultItemListingEvent { data class ShowToast(val text: Text) : VaultItemListingEvent() /** - * Complete the current FIDO 2 credential creation process. + * Complete the current FIDO 2 credential registration process. * - * @property result the result of FIDO 2 credential creation. + * @property result the result of FIDO 2 credential registration. */ data class CompleteFido2Registration( val result: Fido2RegisterCredentialResult, ) : VaultItemListingEvent() + + /** + * Perform user verification for a FIDO 2 credential operation. + */ + data class Fido2UserVerification( + val isRequired: Boolean, + val selectedCipherView: CipherView, + ) : VaultItemListingEvent() } /** @@ -1473,7 +1623,7 @@ sealed class VaultItemListingsAction { data class FolderClick(val id: String) : VaultItemListingsAction() /** - * A master password prompt was encountered when trying to perform a senstive action described + * A master password prompt was encountered when trying to perform a sensitive action described * by the given [masterPasswordRepromptData] and the given [password] was submitted. */ data class MasterPasswordRepromptSubmit( @@ -1486,6 +1636,34 @@ sealed class VaultItemListingsAction { */ data object RefreshPull : VaultItemListingsAction() + /** + * The user has too many failed verification attempts for FIDO operations and can no longer + * use biometric verification for some time. + */ + data object UserVerificationLockOut : VaultItemListingsAction() + + /** + * The user has failed biometric verification for FIDO 2 operations. + */ + data object UserVerificationFail : VaultItemListingsAction() + + /** + * The user has successfully verified themself using biometrics. + */ + data class UserVerificationSuccess( + val selectedCipherView: CipherView, + ) : VaultItemListingsAction() + + /** + * The user has cancelled biometric user verification. + */ + data object UserVerificationCancelled : VaultItemListingsAction() + + /** + * The user cannot perform verification because it is not supported by the device. + */ + data object UserVerificationNotSupported : VaultItemListingsAction() + /** * Models actions that the [VaultItemListingViewModel] itself might send. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingUserVerificationHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingUserVerificationHandlers.kt new file mode 100644 index 000000000..57e038326 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingUserVerificationHandlers.kt @@ -0,0 +1,55 @@ +package com.x8bit.bitwarden.ui.vault.feature.itemlisting.handlers + +import com.bitwarden.vault.CipherView +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingViewModel +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingsAction + +/** + * A collection of handler functions specifically tailored for managing action within the context of + * device user verification. + * + * @property onUserVerificationSuccess Handles the action when biometric verification is + * successful. + * @property onUserVerificationFail Handles the action when biometric verification fails. + * @property onUserVerificationLockOut Handles the action when too many failed verification attempts + * locks out the user for a period of time. + */ +data class VaultItemListingUserVerificationHandlers( + val onUserVerificationSuccess: (selectedCipherView: CipherView) -> Unit, + val onUserVerificationLockOut: () -> Unit, + val onUserVerificationFail: () -> Unit, + val onUserVerificationCancelled: () -> Unit, + val onUserVerificationNotSupported: () -> Unit, +) { + companion object { + + /** + * Creates an instance of [VaultItemListingUserVerificationHandlers] by binding + * actions to the provided [VaultItemListingViewModel]. + */ + fun create( + viewModel: VaultItemListingViewModel, + ): VaultItemListingUserVerificationHandlers = + VaultItemListingUserVerificationHandlers( + onUserVerificationSuccess = { selectedCipherView -> + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationSuccess( + selectedCipherView = selectedCipherView, + ), + ) + }, + onUserVerificationFail = { + viewModel.trySendAction(VaultItemListingsAction.UserVerificationFail) + }, + onUserVerificationLockOut = { + viewModel.trySendAction(VaultItemListingsAction.UserVerificationLockOut) + }, + onUserVerificationCancelled = { + viewModel.trySendAction(VaultItemListingsAction.UserVerificationCancelled) + }, + onUserVerificationNotSupported = { + viewModel.trySendAction(VaultItemListingsAction.UserVerificationNotSupported) + }, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index a7ed193e1..097dc93c8 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -16,11 +16,13 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.core.net.toUri import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText @@ -29,6 +31,7 @@ import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.platform.components.model.IconRes import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType +import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed @@ -51,6 +54,7 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import io.mockk.every +import io.mockk.invoke import io.mockk.just import io.mockk.mockk import io.mockk.mockkStatic @@ -84,6 +88,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { private val fido2CompletionManager: Fido2CompletionManager = mockk { every { completeFido2Registration(any()) } just runs } + private val biometricsManager: BiometricsManager = mockk() private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val viewModel = mockk(relaxed = true) { @@ -100,6 +105,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { viewModel = viewModel, intentManager = intentManager, fido2CompletionManager = fido2CompletionManager, + biometricsManager = biometricsManager, onNavigateBack = { onNavigateBackCalled = true }, onNavigateToVaultItem = { onNavigateToVaultItemId = it }, onNavigateToVaultAddItemScreen = { onNavigateToVaultAddItemScreenCalled = true }, @@ -1477,6 +1483,188 @@ class VaultItemListingScreenTest : BaseComposeTest() { .assertIsDisplayed() .assert(hasAnyAncestor(isDialog())) } + + @Test + fun `CompleteFido2Registration event should call Fido2CompletionManager with result`() { + val result = Fido2RegisterCredentialResult.Success("mockResponse") + mutableEventFlow.tryEmit(VaultItemListingEvent.CompleteFido2Registration(result)) + verify { + fido2CompletionManager.completeFido2Registration(result) + } + } + + @Test + fun `Fido2UserVerification event should perform user verification when it is supported`() { + every { + biometricsManager.promptUserVerification( + any(), + any(), + any(), + any(), + any(), + ) + } just runs + mutableEventFlow.tryEmit( + VaultItemListingEvent.Fido2UserVerification( + isRequired = true, + selectedCipherView = createMockCipherView(number = 1), + ), + ) + verify { + biometricsManager.promptUserVerification( + any(), + any(), + any(), + any(), + any(), + ) + } + } + + @Test + fun `promptForUserVerification onSuccess should send UserVerificationSuccess action`() { + val selectedCipherView = createMockCipherView(number = 1) + every { + biometricsManager.promptUserVerification( + onSuccess = captureLambda(), + any(), + any(), + any(), + any(), + ) + } answers { + lambda<() -> Unit>().invoke() + } + + mutableEventFlow.tryEmit( + VaultItemListingEvent.Fido2UserVerification( + isRequired = true, + selectedCipherView = selectedCipherView, + ), + ) + + verify { + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationSuccess(selectedCipherView), + ) + } + } + + @Test + fun `promptForUserVerification onCancel should send UserVerificationCancelled action`() { + val selectedCipherView = createMockCipherView(number = 1) + every { + biometricsManager.promptUserVerification( + any(), + onCancel = captureLambda(), + any(), + any(), + any(), + ) + } answers { + lambda<() -> Unit>().invoke() + } + + mutableEventFlow.tryEmit( + VaultItemListingEvent.Fido2UserVerification( + isRequired = true, + selectedCipherView = selectedCipherView, + ), + ) + + verify { + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationCancelled, + ) + } + } + + @Test + fun `promptForUserVerification onLockOut should send UserVerificationLockOut action`() { + val selectedCipherView = createMockCipherView(number = 1) + every { + biometricsManager.promptUserVerification( + any(), + any(), + onLockOut = captureLambda(), + any(), + any(), + ) + } answers { + lambda<() -> Unit>().invoke() + } + + mutableEventFlow.tryEmit( + VaultItemListingEvent.Fido2UserVerification( + isRequired = true, + selectedCipherView = selectedCipherView, + ), + ) + + verify { + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationLockOut, + ) + } + } + + @Test + fun `promptForUserVerification onError should send UserVerificationFail action`() { + val selectedCipherView = createMockCipherView(number = 1) + every { + biometricsManager.promptUserVerification( + any(), + any(), + any(), + onError = captureLambda(), + any(), + ) + } answers { + lambda<() -> Unit>().invoke() + } + + mutableEventFlow.tryEmit( + VaultItemListingEvent.Fido2UserVerification( + isRequired = true, + selectedCipherView = selectedCipherView, + ), + ) + + verify { + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationFail, + ) + } + } + + @Test + fun `promptForUserVerification onNotSupported should send UserVerificationNotFailed action`() { + val selectedCipherView = createMockCipherView(number = 1) + every { + biometricsManager.promptUserVerification( + any(), + any(), + any(), + any(), + onNotSupported = captureLambda(), + ) + } answers { + lambda<() -> Unit>().invoke() + } + + mutableEventFlow.tryEmit( + VaultItemListingEvent.Fido2UserVerification( + isRequired = true, + selectedCipherView = selectedCipherView, + ), + ) + + verify { + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationNotSupported, + ) + } + } } private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 786120555..c0f69b384 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -10,12 +10,15 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.PublicKeyCredentialCreationOptions.AuthenticatorSelectionCriteria.UserVerificationRequirement import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult +import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialRequest import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl +import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl @@ -47,6 +50,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.concat import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType +import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPublicKeyCredentialCreationOptions import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.createMockDisplayItemForCipher import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType @@ -114,6 +118,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { every { vaultDataStateFlow } returns mutableVaultDataStateFlow every { lockVault(any()) } just runs every { sync() } just runs + coEvery { + getDecryptedFido2CredentialAutofillViews(any()) + } returns DecryptFido2CredentialAutofillViewResult.Error } private val environmentRepository: EnvironmentRepository = mockk { every { environment } returns Environment.Us @@ -323,6 +330,162 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `ItemClick for vault item during FIDO 2 registration should show FIDO 2 error dialog when cipherView is null`() { + val cipherView = createMockCipherView(number = 1) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = createMockFido2CredentialRequest(number = 1), + ) + mutableVaultDataStateFlow.value = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(), + folderViewList = emptyList(), + collectionViewList = emptyList(), + sendViewList = emptyList(), + ), + ) + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction(VaultItemListingsAction.ItemClick(cipherView.id.orEmpty())) + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_user_could_not_be_verified + .asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `ItemClick for vault item during FIDO 2 registration should show FIDO 2 error dialog when PasskeyCreateOptions is null`() { + setupMockUri() + val cipherView = createMockCipherView(number = 1) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = createMockFido2CredentialRequest(number = 1), + ) + mutableVaultDataStateFlow.value = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(cipherView), + folderViewList = emptyList(), + collectionViewList = emptyList(), + sendViewList = emptyList(), + ), + ) + every { fido2CredentialManager.getPasskeyCreateOptionsOrNull(any()) } returns null + + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction(VaultItemListingsAction.ItemClick(cipherView.id.orEmpty())) + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_user_could_not_be_verified + .asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `ItemClick for vault item during FIDO 2 registration should show loading dialog, then request user verification when required`() = + runTest { + setupMockUri() + val cipherView = createMockCipherView(number = 1) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = createMockFido2CredentialRequest(number = 1), + ) + mutableVaultDataStateFlow.value = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(cipherView), + folderViewList = emptyList(), + collectionViewList = emptyList(), + sendViewList = emptyList(), + ), + ) + every { + fido2CredentialManager.getPasskeyCreateOptionsOrNull(any()) + } returns createMockPublicKeyCredentialCreationOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.REQUIRED, + ) + coEvery { + fido2CredentialManager.registerFido2Credential( + any(), + any(), + any(), + ) + } returns Fido2RegisterCredentialResult.Success("mockResponse") + + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction(VaultItemListingsAction.ItemClick(cipherView.id.orEmpty())) + + viewModel.eventFlow.test { + assertEquals( + VaultItemListingState.DialogState.Loading(R.string.saving.asText()), + viewModel.stateFlow.value.dialogState, + ) + assertEquals( + VaultItemListingEvent.DismissPullToRefresh, + awaitItem(), + ) + assertEquals( + VaultItemListingEvent.Fido2UserVerification( + isRequired = true, + selectedCipherView = cipherView, + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `ItemClick for vault item during FIDO 2 registration should skip user verification and perform registration when discouraged`() = + runTest { + setupMockUri() + val cipherView = createMockCipherView(number = 1) + val mockFido2CredentialRequest = createMockFido2CredentialRequest(number = 1) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = mockFido2CredentialRequest, + ) + mutableVaultDataStateFlow.value = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(cipherView), + folderViewList = emptyList(), + collectionViewList = emptyList(), + sendViewList = emptyList(), + ), + ) + every { + fido2CredentialManager.getPasskeyCreateOptionsOrNull(any()) + } returns createMockPublicKeyCredentialCreationOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.DISCOURAGED, + ) + coEvery { + fido2CredentialManager.registerFido2Credential( + any(), + any(), + any(), + ) + } returns Fido2RegisterCredentialResult.Success("mockResponse") + + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction(VaultItemListingsAction.ItemClick(cipherView.id.orEmpty())) + + coVerify { + fido2CredentialManager.registerFido2Credential( + userId = DEFAULT_USER_STATE.activeUserId, + fido2CredentialRequest = mockFido2CredentialRequest, + selectedCipherView = cipherView, + ) + } + } + @Test fun `ItemClick for vault item should emit NavigateToVaultItem`() = runTest { val viewModel = createVaultItemListingViewModel() @@ -1886,6 +2049,196 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `UserVerificationLockout should display Fido2ErrorDialog`() { + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction(VaultItemListingsAction.UserVerificationLockOut) + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `UserVerificationCancelled should clear dialog state and emit CompleteFido2Create with cancelled result`() = + runTest { + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction(VaultItemListingsAction.UserVerificationCancelled) + + assertNull(viewModel.stateFlow.value.dialogState) + viewModel.eventFlow.test { + assertEquals( + VaultItemListingEvent.CompleteFido2Registration( + result = Fido2RegisterCredentialResult.Cancelled, + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `UserVerificationFail should display Fido2ErrorDialog`() { + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction(VaultItemListingsAction.UserVerificationFail) + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `UserVerificationSuccess should display Fido2ErrorDialog when SpecialCircumstance is null`() = + runTest { + specialCircumstanceManager.specialCircumstance = null + coEvery { + fido2CredentialManager.registerFido2Credential( + any(), + any(), + any(), + ) + } returns Fido2RegisterCredentialResult.Success( + registrationResponse = "mockResponse", + ) + + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationSuccess( + createMockCipherView(number = 1), + ), + ) + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `UserVerificationSuccess should display Fido2ErrorDialog when SpecialCircumstance is invalid`() = + runTest { + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.AutofillSave( + AutofillSaveItem.Login( + username = "mockUsername", + password = "mockPassword", + uri = "mockUri", + ), + ) + coEvery { + fido2CredentialManager.registerFido2Credential( + any(), + any(), + any(), + ) + } returns Fido2RegisterCredentialResult.Success( + registrationResponse = "mockResponse", + ) + + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationSuccess( + selectedCipherView = createMockCipherView(number = 1), + ), + ) + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `UserVerificationSuccess should display Fido2ErrorDialog when activeUserId is null`() { + every { authRepository.activeUserId } returns null + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.Fido2Save(createMockFido2CredentialRequest(number = 1)) + + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationSuccess( + selectedCipherView = createMockCipherView(number = 1), + ), + ) + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_user_could_not_be_verified + .asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `UserVerificationSuccess should register FIDO 2 credential when registration result is received`() = + runTest { + val mockRequest = createMockFido2CredentialRequest(number = 1) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = mockRequest, + ) + coEvery { + fido2CredentialManager.registerFido2Credential( + any(), + any(), + any(), + ) + } returns Fido2RegisterCredentialResult.Success( + registrationResponse = "mockResponse", + ) + + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationSuccess( + selectedCipherView = createMockCipherView(number = 1), + ), + ) + + coVerify { + fido2CredentialManager.registerFido2Credential( + userId = DEFAULT_ACCOUNT.userId, + fido2CredentialRequest = mockRequest, + selectedCipherView = any(), + ) + } + } + + @Test + fun `UserVerificationNotSupported should display Fido2ErrorDialog`() { + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction(VaultItemListingsAction.UserVerificationNotSupported) + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_user_could_not_be_verified + .asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + @Suppress("CyclomaticComplexMethod") private fun createSavedStateHandleWithVaultItemListingType( vaultItemListingType: VaultItemListingType, @@ -1967,6 +2320,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { shouldFinishOnComplete = false, policyDisablesSend = false, hasMasterPassword = true, + fido2CredentialRequest = null, ) }