From ee87d8ada88f74f9c1fe60dc45caea52e781c819 Mon Sep 17 00:00:00 2001 From: Shannon Draeker <125921730+shannon-livefront@users.noreply.github.com> Date: Fri, 19 Jul 2024 15:20:55 -0600 Subject: [PATCH] PM-9684: Verify with master password on item listing (#3585) --- .../fido2/manager/Fido2CredentialManager.kt | 7 +- .../manager/Fido2CredentialManagerImpl.kt | 2 + .../itemlisting/VaultItemListingScreen.kt | 58 +++- .../itemlisting/VaultItemListingViewModel.kt | 196 +++++++++++- ...aultItemListingUserVerificationHandlers.kt | 10 +- .../itemlisting/VaultItemListingScreenTest.kt | 98 +++++- .../VaultItemListingViewModelTest.kt | 302 ++++++++++++++++-- 7 files changed, 627 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt index 24f883713e..5ca120db5c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt @@ -10,13 +10,18 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions * Responsible for managing FIDO 2 credential registration and authentication. */ interface Fido2CredentialManager { - /** * Returns true when the user has performed an explicit verification action. E.g., biometric * verification, device credential verification, or vault unlock. */ var isUserVerified: Boolean + /** + * The number of times the user has attempted to authenticate with their password or PIN + * for the FIDO 2 user verification flow. + */ + var authenticationAttempts: Int + /** * Attempt to validate the RP and origin of the provided [fido2CredentialRequest]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt index 67c453c745..046f9c97af 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt @@ -41,6 +41,8 @@ class Fido2CredentialManagerImpl( override var isUserVerified: Boolean = false + override var authenticationAttempts: Int = 0 + override suspend fun registerFido2Credential( userId: String, fido2CredentialRequest: Fido2CredentialRequest, 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 43c762625e..d98586cb2d 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 @@ -41,6 +41,7 @@ import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingConten import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwritePasskeyConfirmationDialog import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold @@ -155,7 +156,11 @@ fun VaultItemListingScreen( onCancel = userVerificationHandlers.onUserVerificationCancelled, onLockOut = userVerificationHandlers.onUserVerificationLockOut, onError = userVerificationHandlers.onUserVerificationFail, - onNotSupported = userVerificationHandlers.onUserVerificationNotSupported, + onNotSupported = { + userVerificationHandlers.onUserVerificationNotSupported( + event.selectedCipherView.id, + ) + }, ) } } @@ -182,6 +187,30 @@ fun VaultItemListingScreen( ) } }, + onSubmitMasterPasswordFido2Verification = remember(viewModel) { + { password, cipherId -> + viewModel.trySendAction( + VaultItemListingsAction.MasterPasswordFido2VerificationSubmit( + password = password, + selectedCipherId = cipherId, + ), + ) + } + }, + onDismissFido2PasswordVerification = remember(viewModel) { + { + viewModel.trySendAction( + VaultItemListingsAction.DismissFido2PasswordVerificationDialogClick, + ) + } + }, + onRetryFido2PasswordVerification = remember(viewModel) { + { + viewModel.trySendAction( + VaultItemListingsAction.RetryFido2PasswordVerificationClick(it), + ) + } + }, ) VaultItemListingScaffold( @@ -199,6 +228,9 @@ private fun VaultItemListingDialogs( onDismissRequest: () -> Unit, onDismissFido2ErrorDialog: () -> Unit, onConfirmOverwriteExistingPasskey: (cipherViewId: String) -> Unit, + onSubmitMasterPasswordFido2Verification: (password: String, cipherId: String) -> Unit, + onDismissFido2PasswordVerification: () -> Unit, + onRetryFido2PasswordVerification: (cipherViewId: String) -> Unit, ) { when (dialogState) { is VaultItemListingState.DialogState.Error -> BitwardenBasicDialog( @@ -228,6 +260,30 @@ private fun VaultItemListingDialogs( ) } + is VaultItemListingState.DialogState.Fido2MasterPasswordPrompt -> { + BitwardenMasterPasswordDialog( + onConfirmClick = { password -> + onSubmitMasterPasswordFido2Verification( + password, + dialogState.selectedCipherId, + ) + }, + onDismissRequest = onDismissFido2PasswordVerification, + ) + } + + is VaultItemListingState.DialogState.Fido2MasterPasswordError -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialogState.title, + message = dialogState.message, + ), + onDismissRequest = { + onRetryFido2PasswordVerification(dialogState.selectedCipherId) + }, + ) + } + null -> Unit } } 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 ad48ae9b20..6ec8948bb0 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 @@ -8,6 +8,7 @@ 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.auth.repository.model.VaultUnlockType 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 @@ -184,6 +185,18 @@ class VaultItemListingViewModel @Inject constructor( handleDismissFido2ErrorDialogClick() } + is VaultItemListingsAction.MasterPasswordFido2VerificationSubmit -> { + handleMasterPasswordFido2VerificationSubmit(action) + } + + VaultItemListingsAction.DismissFido2PasswordVerificationDialogClick -> { + handleDismissFido2PasswordVerificationDialogClick() + } + + is VaultItemListingsAction.RetryFido2PasswordVerificationClick -> { + handleRetryFido2PasswordVerificationClick(action) + } + is VaultItemListingsAction.BackClick -> handleBackClick() is VaultItemListingsAction.FolderClick -> handleFolderClick(action) is VaultItemListingsAction.CollectionClick -> handleCollectionClick(action) @@ -219,8 +232,8 @@ class VaultItemListingViewModel @Inject constructor( handleUserVerificationSuccess(action) } - VaultItemListingsAction.UserVerificationNotSupported -> { - handleUserVerificationNotSupported() + is VaultItemListingsAction.UserVerificationNotSupported -> { + handleUserVerificationNotSupported(action) } is VaultItemListingsAction.Internal -> handleInternalAction(action) @@ -279,17 +292,20 @@ class VaultItemListingViewModel @Inject constructor( action: VaultItemListingsAction.UserVerificationSuccess, ) { fido2CredentialManager.isUserVerified = true + getRequestAndRegisterCredential(cipherView = action.selectedCipherView) + } + + private fun getRequestAndRegisterCredential(cipherView: CipherView) = specialCircumstanceManager .specialCircumstance ?.toFido2RequestOrNull() ?.let { request -> registerFido2CredentialToCipher( request = request, - cipherView = action.selectedCipherView, + cipherView = cipherView, ) } ?: showFido2ErrorDialog() - } private fun handleUserVerificationFail() { fido2CredentialManager.isUserVerified = false @@ -306,11 +322,73 @@ class VaultItemListingViewModel @Inject constructor( ) } - private fun handleUserVerificationNotSupported() { + private fun handleUserVerificationNotSupported( + action: VaultItemListingsAction.UserVerificationNotSupported, + ) { fido2CredentialManager.isUserVerified = false + + val selectedCipherId = action + .selectedCipherId + ?: run { + showFido2ErrorDialog() + return + } + + val activeAccount = authRepository + .userStateFlow + .value + ?.activeAccount + ?: run { + showFido2ErrorDialog() + return + } + + if (activeAccount.vaultUnlockType == VaultUnlockType.PIN) { + // TODO: https://bitwarden.atlassian.net/browse/PM-9682 + } else if (activeAccount.hasMasterPassword) { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Fido2MasterPasswordPrompt( + selectedCipherId = selectedCipherId, + ), + ) + } + } else { + // Prompt the user to set up a PIN for their account. + // TODO: https://bitwarden.atlassian.net/browse/PM-9681 + } + } + + private fun handleMasterPasswordFido2VerificationSubmit( + action: VaultItemListingsAction.MasterPasswordFido2VerificationSubmit, + ) { + viewModelScope.launch { + val result = authRepository.validatePassword(action.password) + sendAction( + VaultItemListingsAction.Internal.ValidateFido2PasswordResultReceive( + result = result, + selectedCipherId = action.selectedCipherId, + ), + ) + } + } + + private fun handleDismissFido2PasswordVerificationDialogClick() { showFido2ErrorDialog() } + private fun handleRetryFido2PasswordVerificationClick( + action: VaultItemListingsAction.RetryFido2PasswordVerificationClick, + ) { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Fido2MasterPasswordPrompt( + selectedCipherId = action.selectedCipherId, + ), + ) + } + } + private fun handleCopySendUrlClick(action: ListingItemOverflowAction.SendAction.CopyUrlClick) { clipboardManager.setText(text = action.sendUrl) } @@ -480,8 +558,8 @@ class VaultItemListingViewModel @Inject constructor( } null -> { - // Per WebAuthn spec members should be ignored when invalid. Since the request - // violates spec we display an error and terminate the operation. + // Per WebAuthn spec, members should be ignored when invalid. Since the request + // violates spec, we display an error and terminate the operation. showFido2ErrorDialog() } } @@ -709,6 +787,10 @@ class VaultItemListingViewModel @Inject constructor( handleMasterPasswordRepromptResultReceive(action) } + is VaultItemListingsAction.Internal.ValidateFido2PasswordResultReceive -> { + handleValidateFido2PasswordResultReceive(action) + } + is VaultItemListingsAction.Internal.PolicyUpdateReceive -> { handlePolicyUpdateReceive(action) } @@ -876,6 +958,51 @@ class VaultItemListingViewModel @Inject constructor( } } } + + private fun handleValidateFido2PasswordResultReceive( + action: VaultItemListingsAction.Internal.ValidateFido2PasswordResultReceive, + ) { + mutableStateFlow.update { it.copy(dialogState = null) } + + when (action.result) { + ValidatePasswordResult.Error -> { + showFido2ErrorDialog() + } + + is ValidatePasswordResult.Success -> { + if (!action.result.isValid) { + fido2CredentialManager.authenticationAttempts += 1 + if (fido2CredentialManager.authenticationAttempts < 5) { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState + .DialogState + .Fido2MasterPasswordError( + title = null, + message = R.string.invalid_master_password.asText(), + selectedCipherId = action.selectedCipherId, + ), + ) + } + } else { + showFido2ErrorDialog() + } + return + } + + fido2CredentialManager.isUserVerified = true + fido2CredentialManager.authenticationAttempts = 0 + + val cipherView = getCipherViewOrNull(cipherId = action.selectedCipherId) + ?: run { + showFido2ErrorDialog() + return + } + + getRequestAndRegisterCredential(cipherView = cipherView) + } + } + } //endregion VaultItemListing Handlers private fun vaultErrorReceive(vaultData: DataState.Error) { @@ -1123,6 +1250,7 @@ class VaultItemListingViewModel @Inject constructor( ?.fido2CredentialAutofillViews private fun showFido2ErrorDialog() { + fido2CredentialManager.authenticationAttempts = 0 mutableStateFlow.update { it.copy( dialogState = VaultItemListingState.DialogState.Fido2CreationFail( @@ -1245,6 +1373,26 @@ data class VaultItemListingState( */ @Parcelize data class OverwritePasskeyConfirmationPrompt(val cipherViewId: String) : DialogState() + + /** + * Represents a dialog to prompt the user for their master password as part of the FIDO 2 + * user verification flow. + */ + @Parcelize + data class Fido2MasterPasswordPrompt( + val selectedCipherId: String, + ) : DialogState() + + /** + * Represents a dialog to alert the user that their password for the FIDO 2 user + * verification flow was incorrect and to retry. + */ + @Parcelize + data class Fido2MasterPasswordError( + val title: Text?, + val message: Text, + val selectedCipherId: String, + ) : DialogState() } /** @@ -1623,6 +1771,26 @@ sealed class VaultItemListingsAction { */ data object DismissFido2CreationErrorDialogClick : VaultItemListingsAction() + /** + * Click to submit the master password for FIDO 2 verification. + */ + data class MasterPasswordFido2VerificationSubmit( + val password: String, + val selectedCipherId: String, + ) : VaultItemListingsAction() + + /** + * Click to dismiss the FIDO 2 password verification dialog. + */ + data object DismissFido2PasswordVerificationDialogClick : VaultItemListingsAction() + + /** + * Click to retry the FIDO 2 password verification. + */ + data class RetryFido2PasswordVerificationClick( + val selectedCipherId: String, + ) : VaultItemListingsAction() + /** * Click the refresh button. */ @@ -1721,7 +1889,9 @@ sealed class VaultItemListingsAction { /** * The user cannot perform verification because it is not supported by the device. */ - data object UserVerificationNotSupported : VaultItemListingsAction() + data class UserVerificationNotSupported( + val selectedCipherId: String?, + ) : VaultItemListingsAction() /** * The user has confirmed overwriting the existing cipher's passkey. @@ -1780,6 +1950,15 @@ sealed class VaultItemListingsAction { val result: ValidatePasswordResult, ) : Internal() + /** + * Indicates that a result for verifying the user's master password as part of the FIDO 2 + * user verification flow has been received. + */ + data class ValidateFido2PasswordResultReceive( + val result: ValidatePasswordResult, + val selectedCipherId: String, + ) : Internal() + /** * Indicates that a policy update has been received. */ @@ -1808,7 +1987,6 @@ sealed class VaultItemListingsAction { * Data tracking the type of request that triggered a master password reprompt. */ sealed class MasterPasswordRepromptData : Parcelable { - /** * Autofill was selected. */ 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 index 57e0383262..d0b1163b51 100644 --- 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 @@ -19,7 +19,7 @@ data class VaultItemListingUserVerificationHandlers( val onUserVerificationLockOut: () -> Unit, val onUserVerificationFail: () -> Unit, val onUserVerificationCancelled: () -> Unit, - val onUserVerificationNotSupported: () -> Unit, + val onUserVerificationNotSupported: (selectedCipherId: String?) -> Unit, ) { companion object { @@ -47,8 +47,12 @@ data class VaultItemListingUserVerificationHandlers( onUserVerificationCancelled = { viewModel.trySendAction(VaultItemListingsAction.UserVerificationCancelled) }, - onUserVerificationNotSupported = { - viewModel.trySendAction(VaultItemListingsAction.UserVerificationNotSupported) + onUserVerificationNotSupported = { selectedCipherId -> + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationNotSupported( + selectedCipherId = selectedCipherId, + ), + ) }, ) } 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 1ecc2b0566..946a4deb63 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 @@ -1484,6 +1484,100 @@ class VaultItemListingScreenTest : BaseComposeTest() { .assert(hasAnyAncestor(isDialog())) } + @Test + fun `fido2 master password prompt dialog should display and function according to state`() { + val selectedCipherId = "selectedCipherId" + val dialogTitle = "Master password confirmation" + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule.onNodeWithText(dialogTitle).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Fido2MasterPasswordPrompt( + selectedCipherId = selectedCipherId, + ), + ) + } + + composeTestRule + .onNodeWithText(dialogTitle) + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + + composeTestRule + .onAllNodesWithText(text = "Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { + viewModel.trySendAction( + VaultItemListingsAction.DismissFido2PasswordVerificationDialogClick, + ) + } + + composeTestRule + .onAllNodesWithText(text = "Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { + viewModel.trySendAction( + VaultItemListingsAction.DismissFido2PasswordVerificationDialogClick, + ) + } + + composeTestRule + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .performTextInput("password") + composeTestRule + .onAllNodesWithText(text = "Submit") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction( + VaultItemListingsAction.MasterPasswordFido2VerificationSubmit( + password = "password", + selectedCipherId = selectedCipherId, + ), + ) + } + } + + @Test + fun `fido2 master password error dialog should display and function according to state`() { + val selectedCipherId = "selectedCipherId" + val dialogMessage = "Invalid master password. Try again." + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule.onNodeWithText(dialogMessage).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Fido2MasterPasswordError( + title = null, + message = dialogMessage.asText(), + selectedCipherId = selectedCipherId, + ), + ) + } + + composeTestRule + .onNodeWithText(dialogMessage) + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + + composeTestRule + .onAllNodesWithText(text = "Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { + viewModel.trySendAction( + VaultItemListingsAction.RetryFido2PasswordVerificationClick( + selectedCipherId = selectedCipherId, + ), + ) + } + } + @Test fun `CompleteFido2Registration event should call Fido2CompletionManager with result`() { val result = Fido2RegisterCredentialResult.Success("mockResponse") @@ -1661,7 +1755,9 @@ class VaultItemListingScreenTest : BaseComposeTest() { verify { viewModel.trySendAction( - VaultItemListingsAction.UserVerificationNotSupported, + VaultItemListingsAction.UserVerificationNotSupported( + selectedCipherId = selectedCipherView.id, + ), ) } } 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 ee6d7f9d8d..a3503650ea 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 @@ -144,6 +144,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { coEvery { validateOrigin(any()) } returns Fido2ValidateOriginResult.Success every { isUserVerified } returns false every { isUserVerified = any() } just runs + every { authenticationAttempts } returns 0 + every { authenticationAttempts = any() } just runs } private val organizationEventManager = mockk { @@ -2337,11 +2339,15 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } - @Suppress("MaxLineLength") @Test - fun `UserVerificationNotSupported should display Fido2ErrorDialog and set isUserVerified to false`() { + fun `UserVerificationNotSupported should display Fido2CreationFail when no cipher id found`() { val viewModel = createVaultItemListingViewModel() - viewModel.trySendAction(VaultItemListingsAction.UserVerificationNotSupported) + + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationNotSupported( + selectedCipherId = null, + ), + ) verify { fido2CredentialManager.isUserVerified = false } assertEquals( @@ -2354,6 +2360,239 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `UserVerificationNotSupported should display Fido2CreationFail when no active account found`() { + val viewModel = createVaultItemListingViewModel() + val selectedCipherId = "selectedCipherId" + mutableUserStateFlow.value = null + + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationNotSupported( + selectedCipherId = selectedCipherId, + ), + ) + + verify { fido2CredentialManager.isUserVerified = false } + 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 `UserVerificationNotSupported should display Fido2MasterPasswordPrompt when user has password but no pin`() { + val viewModel = createVaultItemListingViewModel() + val selectedCipherId = "selectedCipherId" + + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationNotSupported( + selectedCipherId = selectedCipherId, + ), + ) + + verify { fido2CredentialManager.isUserVerified = false } + assertEquals( + VaultItemListingState.DialogState.Fido2MasterPasswordPrompt( + selectedCipherId = selectedCipherId, + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `MasterPasswordFido2VerificationSubmit should display Fido2ErrorDialog when password verification fails`() { + val viewModel = createVaultItemListingViewModel() + val selectedCipherId = "selectedCipherId" + val password = "password" + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Error + + viewModel.trySendAction( + VaultItemListingsAction.MasterPasswordFido2VerificationSubmit( + password = password, + selectedCipherId = selectedCipherId, + ), + ) + + 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, + ) + coVerify { + authRepository.validatePassword(password = password) + } + } + + @Suppress("MaxLineLength") + @Test + fun `MasterPasswordFido2VerificationSubmit should display Fido2MasterPasswordError when user has retries remaining`() { + val viewModel = createVaultItemListingViewModel() + val selectedCipherId = "selectedCipherId" + val password = "password" + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Success(isValid = false) + + viewModel.trySendAction( + VaultItemListingsAction.MasterPasswordFido2VerificationSubmit( + password = password, + selectedCipherId = selectedCipherId, + ), + ) + + assertEquals( + VaultItemListingState.DialogState.Fido2MasterPasswordError( + title = null, + message = R.string.invalid_master_password.asText(), + selectedCipherId = selectedCipherId, + ), + viewModel.stateFlow.value.dialogState, + ) + coVerify { + authRepository.validatePassword(password = password) + } + } + + @Suppress("MaxLineLength") + @Test + fun `MasterPasswordFido2VerificationSubmit should display Fido2ErrorDialog when user has no retries remaining`() { + val viewModel = createVaultItemListingViewModel() + val selectedCipherId = "selectedCipherId" + val password = "password" + every { fido2CredentialManager.authenticationAttempts } returns 5 + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Success(isValid = false) + + viewModel.trySendAction( + VaultItemListingsAction.MasterPasswordFido2VerificationSubmit( + password = password, + selectedCipherId = selectedCipherId, + ), + ) + + 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, + ) + coVerify { + authRepository.validatePassword(password = password) + } + } + + @Suppress("MaxLineLength") + @Test + fun `MasterPasswordFido2VerificationSubmit should display Fido2ErrorDialog when cipher not found`() { + val viewModel = createVaultItemListingViewModel() + val selectedCipherId = "selectedCipherId" + val password = "password" + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Success(isValid = true) + + viewModel.trySendAction( + VaultItemListingsAction.MasterPasswordFido2VerificationSubmit( + password = password, + selectedCipherId = selectedCipherId, + ), + ) + + 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, + ) + coVerify { + authRepository.validatePassword(password = password) + } + } + + @Suppress("MaxLineLength") + @Test + fun `MasterPasswordFido2VerificationSubmit should register credential when password authenticated successfully`() = + runTest { + val viewModel = createVaultItemListingViewModel() + val cipherView = createMockCipherView(number = 1) + val selectedCipherId = cipherView.id ?: "" + val password = "password" + mutableVaultDataStateFlow.value = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(cipherView), + collectionViewList = emptyList(), + folderViewList = emptyList(), + sendViewList = emptyList(), + ), + ) + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Success(isValid = true) + + viewModel.trySendAction( + VaultItemListingsAction.MasterPasswordFido2VerificationSubmit( + password = password, + selectedCipherId = selectedCipherId, + ), + ) + coVerify { + authRepository.validatePassword(password = password) + } + } + + @Test + fun `DismissFido2PasswordVerificationDialogClick should display Fido2ErrorDialog`() { + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction( + VaultItemListingsAction.DismissFido2PasswordVerificationDialogClick, + ) + + 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, + ) + } + + @Test + fun `RetryFido2PasswordVerificationClick should display Fido2MasterPasswordPrompt`() { + val viewModel = createVaultItemListingViewModel() + val selectedCipherId = "selectedCipherId" + + viewModel.trySendAction( + VaultItemListingsAction.RetryFido2PasswordVerificationClick( + selectedCipherId = selectedCipherId, + ), + ) + + assertEquals( + VaultItemListingState.DialogState.Fido2MasterPasswordPrompt( + selectedCipherId = selectedCipherId, + ), + viewModel.stateFlow.value.dialogState, + ) + } + @Test fun `ConfirmOverwriteExistingPasskeyClick should check if user is verified`() = runTest { @@ -2389,35 +2628,36 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `ConfirmOverwriteExistingPasskeyClick should display Fido2ErrorDialog when getSelectedCipher returns null`() = 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(), - ), - ) - val viewModel = createVaultItemListingViewModel() - viewModel.trySendAction( - VaultItemListingsAction.ConfirmOverwriteExistingPasskeyClick( - cipherViewId = "invalidId", - ), - ) + fun `ConfirmOverwriteExistingPasskeyClick should display Fido2ErrorDialog when getSelectedCipher returns null`() = + 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(), + ), + ) + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction( + VaultItemListingsAction.ConfirmOverwriteExistingPasskeyClick( + cipherViewId = "invalidId", + ), + ) - assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( - R.string.an_error_has_occurred.asText(), - R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), - ), - viewModel.stateFlow.value.dialogState, - ) - } + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + R.string.an_error_has_occurred.asText(), + R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } @Suppress("CyclomaticComplexMethod") private fun createSavedStateHandleWithVaultItemListingType(