From 74132de8ed8efe7bd47d615ef3bb82cad768a4e3 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Fri, 26 Jul 2024 13:18:29 -0400 Subject: [PATCH] [PM-9409] Authenticate selected FIDO 2 credential (#3630) --- .../processor/Fido2ProviderProcessorImpl.kt | 43 +- .../itemlisting/VaultItemListingScreen.kt | 8 +- .../itemlisting/VaultItemListingViewModel.kt | 244 ++++++- .../processor/Fido2ProviderProcessorTest.kt | 56 +- .../sdk/model/Fido2CredentialViewUtil.kt | 2 +- .../PasskeyAssertionOptionsTestHelpers.kt | 4 +- .../itemlisting/VaultItemListingScreenTest.kt | 36 + .../VaultItemListingViewModelTest.kt | 626 +++++++++++++++++- .../VaultItemListingDataExtensionsTest.kt | 58 +- 9 files changed, 950 insertions(+), 127 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorImpl.kt index dcc0379d48..632e047f51 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorImpl.kt @@ -31,8 +31,10 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager +import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -188,7 +190,7 @@ class Fido2ProviderProcessorImpl( } } - @Throws + @Throws(GetCredentialUnsupportedException::class) private suspend fun getMatchingFido2CredentialEntries( userId: String, request: BeginGetCredentialRequest, @@ -201,24 +203,37 @@ class Fido2ProviderProcessorImpl( .getPasskeyAssertionOptionsOrNull(requestJson = option.requestJson) ?.relyingPartyId ?: throw GetCredentialUnknownException("Invalid data.") - - vaultRepository - .silentlyDiscoverCredentials( - userId = userId, - fido2CredentialStore = fido2CredentialStore, - relyingPartyId = relyingPartyId, - ) - .fold( - onSuccess = { it.toCredentialEntries(option) }, - onFailure = { - throw GetCredentialUnknownException("Error decrypting credentials.") - }, - ) + buildCredentialEntries(relyingPartyId, option) } else { throw GetCredentialUnsupportedException("Unsupported option.") } } + private suspend fun buildCredentialEntries( + relyingPartyId: String, + option: BeginGetPublicKeyCredentialOption, + ): List { + val cipherViews = vaultRepository + .ciphersStateFlow + .value + .data + ?.filter { it.isActiveWithFido2Credentials } + ?: emptyList() + val result = vaultRepository + .getDecryptedFido2CredentialAutofillViews(cipherViews) + return when (result) { + DecryptFido2CredentialAutofillViewResult.Error -> { + throw GetCredentialUnknownException("Error decrypting credentials.") + } + is DecryptFido2CredentialAutofillViewResult.Success -> { + result + .fido2CredentialAutofillViews + .filter { it.rpId == relyingPartyId } + .toCredentialEntries(option) + } + } + } + private fun List.toCredentialEntries( option: BeginGetPublicKeyCredentialOption, ): List = 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 35c7a71ffd..e750f0d752 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 @@ -165,6 +165,10 @@ fun VaultItemListingScreen( }, ) } + + is VaultItemListingEvent.CompleteFido2Assertion -> { + fido2CompletionManager.completeFido2Assertion(event.result) + } } } @@ -176,7 +180,7 @@ fun VaultItemListingScreen( onDismissFido2ErrorDialog = remember(viewModel) { { viewModel.trySendAction( - VaultItemListingsAction.DismissFido2CreationErrorDialogClick, + VaultItemListingsAction.DismissFido2ErrorDialogClick, ) } }, @@ -286,7 +290,7 @@ private fun VaultItemListingDialogs( visibilityState = LoadingDialogState.Shown(dialogState.message), ) - is VaultItemListingState.DialogState.Fido2CreationFail -> BitwardenBasicDialog( + is VaultItemListingState.DialogState.Fido2OperationFail -> BitwardenBasicDialog( visibilityState = BasicDialogState.Shown( title = dialogState.title, message = dialogState.message, 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 9154ad1ebc..61f800d81a 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 @@ -11,6 +11,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult 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.Fido2CredentialAssertionRequest +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult 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 @@ -25,6 +27,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.toFido2AssertionRequestOrNull 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 @@ -100,9 +103,10 @@ class VaultItemListingViewModel @Inject constructor( val specialCircumstance = specialCircumstanceManager.specialCircumstance val autofillSelectionData = specialCircumstance as? SpecialCircumstance.AutofillSelection val fido2CreationData = specialCircumstance as? SpecialCircumstance.Fido2Save + val fido2AssertionData = specialCircumstance as? SpecialCircumstance.Fido2Assertion val shouldFinishOnComplete = autofillSelectionData ?.shouldFinishWhenComplete - ?: (fido2CreationData != null) + ?: (fido2CreationData != null || fido2AssertionData != null) val dialogState = fido2CreationData ?.let { VaultItemListingState.DialogState.Loading(R.string.loading.asText()) } VaultItemListingState( @@ -125,6 +129,7 @@ class VaultItemListingViewModel @Inject constructor( shouldFinishOnComplete = shouldFinishOnComplete, hasMasterPassword = userState.activeAccount.hasMasterPassword, fido2CredentialRequest = fido2CreationData?.fido2CredentialRequest, + fido2CredentialAssertionRequest = fido2AssertionData?.fido2AssertionRequest, isPremium = userState.activeAccount.isPremium, ) }, @@ -152,6 +157,14 @@ class VaultItemListingViewModel @Inject constructor( ), ) } + ?: state.fido2CredentialAssertionRequest + ?.let { request -> + sendAction( + VaultItemListingsAction.Internal.Fido2AssertionDataReceive( + data = request, + ), + ) + } ?: observeVaultData() } @@ -183,7 +196,7 @@ class VaultItemListingViewModel @Inject constructor( is VaultItemListingsAction.LogoutAccountClick -> handleLogoutAccountClick(action) is VaultItemListingsAction.SwitchAccountClick -> handleSwitchAccountClick(action) is VaultItemListingsAction.DismissDialogClick -> handleDismissDialogClick() - is VaultItemListingsAction.DismissFido2CreationErrorDialogClick -> { + is VaultItemListingsAction.DismissFido2ErrorDialogClick -> { handleDismissFido2ErrorDialogClick() } @@ -308,21 +321,9 @@ class VaultItemListingViewModel @Inject constructor( action: VaultItemListingsAction.UserVerificationSuccess, ) { fido2CredentialManager.isUserVerified = true - getRequestAndRegisterCredential(cipherView = action.selectedCipherView) + continueFido2Operation(action.selectedCipherView) } - private fun getRequestAndRegisterCredential(cipherView: CipherView) = - specialCircumstanceManager - .specialCircumstance - ?.toFido2RequestOrNull() - ?.let { request -> - registerFido2CredentialToCipher( - request = request, - cipherView = cipherView, - ) - } - ?: showFido2ErrorDialog() - private fun handleUserVerificationFail() { fido2CredentialManager.isUserVerified = false showFido2ErrorDialog() @@ -679,6 +680,36 @@ class VaultItemListingViewModel @Inject constructor( } } + private fun authenticateFido2Credential( + request: Fido2CredentialAssertionRequest, + cipherView: CipherView, + ) { + val activeUserId = authRepository.activeUserId + ?: run { + showFido2ErrorDialog() + return + } + viewModelScope.launch { + val result = fido2CredentialManager + .authenticateFido2Credential( + userId = activeUserId, + selectedCipherView = cipherView, + request = Fido2CredentialAssertionRequest( + cipherId = request.cipherId, + credentialId = request.credentialId, + requestJson = request.requestJson, + clientDataHash = request.clientDataHash, + packageName = request.packageName, + signingInfo = request.signingInfo, + origin = request.origin, + ), + ) + sendAction( + VaultItemListingsAction.Internal.Fido2AssertionResultReceive(result), + ) + } + } + private fun handleMasterPasswordRepromptSubmit( action: VaultItemListingsAction.MasterPasswordRepromptSubmit, ) { @@ -756,11 +787,34 @@ class VaultItemListingViewModel @Inject constructor( private fun handleDismissFido2ErrorDialogClick() { clearDialogState() - sendEvent( - VaultItemListingEvent.CompleteFido2Registration( - result = Fido2RegisterCredentialResult.Error, - ), - ) + when { + state.fido2CredentialRequest != null -> { + sendEvent( + VaultItemListingEvent.CompleteFido2Registration( + result = Fido2RegisterCredentialResult.Error, + ), + ) + } + + state.fido2CredentialAssertionRequest != null -> { + sendEvent( + VaultItemListingEvent.CompleteFido2Assertion( + result = Fido2CredentialAssertionResult.Error, + ), + ) + } + + else -> { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ) + } + } + } } private fun handleBackClick() { @@ -898,6 +952,14 @@ class VaultItemListingViewModel @Inject constructor( is VaultItemListingsAction.Internal.Fido2RegisterCredentialResultReceive -> { handleFido2RegisterCredentialResultReceive(action) } + + is VaultItemListingsAction.Internal.Fido2AssertionDataReceive -> { + handleFido2AssertionDataReceive(action) + } + + is VaultItemListingsAction.Internal.Fido2AssertionResultReceive -> { + handleFido2AssertionResultReceive(action) + } } } @@ -1134,9 +1196,30 @@ class VaultItemListingViewModel @Inject constructor( return } - getRequestAndRegisterCredential(cipherView = cipherView) + continueFido2Operation(cipherView) } + private fun continueFido2Operation(cipherView: CipherView) { + specialCircumstanceManager + .specialCircumstance + ?.toFido2RequestOrNull() + ?.let { request -> + registerFido2CredentialToCipher( + request = request, + cipherView = cipherView, + ) + } + ?: specialCircumstanceManager + .specialCircumstance + ?.toFido2AssertionRequestOrNull() + ?.let { request -> + authenticateFido2Credential( + request = request, + cipherView = cipherView, + ) + } + ?: showFido2ErrorDialog() + } //endregion VaultItemListing Handlers private fun vaultErrorReceive(vaultData: DataState.Error) { @@ -1265,7 +1348,7 @@ class VaultItemListingViewModel @Inject constructor( } mutableStateFlow.update { it.copy( - dialogState = VaultItemListingState.DialogState.Fido2CreationFail( + dialogState = VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = messageResId.asText(), ), @@ -1277,6 +1360,81 @@ class VaultItemListingViewModel @Inject constructor( observeVaultData() } + private fun handleFido2AssertionDataReceive( + action: VaultItemListingsAction.Internal.Fido2AssertionDataReceive, + ) { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Loading( + message = R.string.loading.asText(), + ), + ) + } + val request = action.data + val ciphers = vaultRepository + .ciphersStateFlow + .value + .data + .orEmpty() + .filter { it.isActiveWithFido2Credentials } + if (request.cipherId.isNullOrEmpty()) { + showFido2ErrorDialog() + } else { + val selectedCipher = ciphers + .find { it.id == request.cipherId } + ?: run { + showFido2ErrorDialog() + return + } + verifyUserAndAuthenticateCredential(request, selectedCipher) + } + } + + private fun verifyUserAndAuthenticateCredential( + request: Fido2CredentialAssertionRequest, + selectedCipher: CipherView, + ) { + val assertionOptions = fido2CredentialManager + .getPasskeyAssertionOptionsOrNull(request.requestJson) + ?: run { + showFido2ErrorDialog() + return + } + + if (fido2CredentialManager.isUserVerified) { + authenticateFido2Credential(request, selectedCipher) + return + } + + when (assertionOptions.userVerification) { + UserVerificationRequirement.DISCOURAGED -> { + authenticateFido2Credential(request, selectedCipher) + } + + UserVerificationRequirement.PREFERRED -> { + sendUserVerificationEvent(isRequired = false, selectedCipher = selectedCipher) + } + + UserVerificationRequirement.REQUIRED -> { + sendUserVerificationEvent(isRequired = true, selectedCipher = selectedCipher) + } + + null -> { + showFido2ErrorDialog() + } + } + } + + private fun handleFido2AssertionResultReceive( + action: VaultItemListingsAction.Internal.Fido2AssertionResultReceive, + ) { + fido2CredentialManager.isUserVerified = false + clearDialogState() + sendEvent( + VaultItemListingEvent.CompleteFido2Assertion(action.result), + ) + } + private fun updateStateWithVaultData(vaultData: VaultData, clearDialogState: Boolean) { mutableStateFlow.update { currentState -> currentState.copy( @@ -1291,8 +1449,8 @@ class VaultItemListingViewModel @Inject constructor( viewState = when (val listingType = currentState.itemListingType) { is VaultItemListingState.ItemListingType.Vault -> { vaultData.toViewState( - vaultFilterType = state.vaultFilterType, itemListingType = listingType, + vaultFilterType = state.vaultFilterType, hasMasterPassword = state.hasMasterPassword, baseIconUrl = state.baseIconUrl, isIconLoadingDisabled = state.isIconLoadingDisabled, @@ -1329,6 +1487,10 @@ class VaultItemListingViewModel @Inject constructor( ?.cipherViewList ?.firstOrNull { it.id == cipherId } + private fun sendUserVerificationEvent(isRequired: Boolean, selectedCipher: CipherView) { + sendEvent(VaultItemListingEvent.Fido2UserVerification(isRequired, selectedCipher)) + } + /** * Takes the given vault data and filters it for autofill if necessary. */ @@ -1387,7 +1549,7 @@ class VaultItemListingViewModel @Inject constructor( fido2CredentialManager.authenticationAttempts = 0 mutableStateFlow.update { it.copy( - dialogState = VaultItemListingState.DialogState.Fido2CreationFail( + dialogState = VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -1419,6 +1581,7 @@ data class VaultItemListingState( private val isPullToRefreshSettingEnabled: Boolean, val autofillSelectionData: AutofillSelectionData? = null, val fido2CredentialRequest: Fido2CredentialRequest? = null, + val fido2CredentialAssertionRequest: Fido2CredentialAssertionRequest? = null, val shouldFinishOnComplete: Boolean = false, val hasMasterPassword: Boolean, val isPremium: Boolean, @@ -1485,11 +1648,10 @@ data class VaultItemListingState( ) : DialogState() /** - * Represents a dialog indicating that the FIDO 2 credential creation flow was not - * successful. + * Represents a dialog indicating that a FIDO 2 credential operation encountered an error. */ @Parcelize - data class Fido2CreationFail( + data class Fido2OperationFail( val title: Text, val message: Text, ) : DialogState() @@ -1892,7 +2054,7 @@ sealed class VaultItemListingEvent { /** * Complete the current FIDO 2 credential registration process. * - * @property result the result of FIDO 2 credential registration. + * @property result The result of FIDO 2 credential registration. */ data class CompleteFido2Registration( val result: Fido2RegisterCredentialResult, @@ -1905,6 +2067,16 @@ sealed class VaultItemListingEvent { val isRequired: Boolean, val selectedCipherView: CipherView, ) : VaultItemListingEvent() + + /** + * FIDO 2 credential assertion result has been received and the process is ready to be + * completed. + * + * @property result The result of the FIDO 2 credential assertion. + */ + data class CompleteFido2Assertion( + val result: Fido2CredentialAssertionResult, + ) : VaultItemListingEvent() } /** @@ -1942,7 +2114,7 @@ sealed class VaultItemListingsAction { /** * Click to dismiss the FIDO 2 creation error dialog. */ - data object DismissFido2CreationErrorDialogClick : VaultItemListingsAction() + data object DismissFido2ErrorDialogClick : VaultItemListingsAction() /** * Click to submit the master password for FIDO 2 verification. @@ -2192,6 +2364,20 @@ sealed class VaultItemListingsAction { data class Fido2RegisterCredentialResultReceive( val result: Fido2RegisterCredentialResult, ) : Internal() + + /** + * Indicates that FIDO 2 assertion request data has been received. + */ + data class Fido2AssertionDataReceive( + val data: Fido2CredentialAssertionRequest, + ) : Internal() + + /** + * Indicates that a result of a FIDO 2 credential assertion has been received. + */ + data class Fido2AssertionResultReceive( + val result: Fido2CredentialAssertionResult, + ) : Internal() } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorTest.kt index 2d0180b6a1..0e354deb7d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/processor/Fido2ProviderProcessorTest.kt @@ -36,10 +36,10 @@ import com.x8bit.bitwarden.data.platform.util.asSuccess import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFido2CredentialAutofillView import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAssertionOptions import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -387,6 +387,9 @@ class Fido2ProviderProcessorTest { mutableCiphersStateFlow.value = DataState.Loaded(mockCipherViews) every { cancellationSignal.setOnCancelListener(any()) } just runs every { callback.onError(capture(captureSlot)) } just runs + coEvery { + vaultRepository.getDecryptedFido2CredentialAutofillViews(any()) + } returns DecryptFido2CredentialAutofillViewResult.Error coEvery { vaultRepository.silentlyDiscoverCredentials( userId = DEFAULT_USER_STATE.activeUserId, @@ -399,13 +402,14 @@ class Fido2ProviderProcessorTest { verify(exactly = 1) { callback.onError(any()) } verify(exactly = 0) { callback.onResult(any()) } - coVerify(exactly = 1) { - vaultRepository.silentlyDiscoverCredentials( - userId = DEFAULT_USER_STATE.activeUserId, - fido2CredentialStore = fido2CredentialStore, - relyingPartyId = "mockRelyingPartyId-1", - ) - } + // TODO: [PM-9515] Uncomment when SDK bug is fixed. + // coVerify(exactly = 1) { + // vaultRepository.silentlyDiscoverCredentials( + // userId = DEFAULT_USER_STATE.activeUserId, + // fido2CredentialStore = fido2CredentialStore, + // relyingPartyId = "mockRelyingPartyId-1", + // ) + // } assert(captureSlot.captured is GetCredentialUnknownException) assertEquals("Error decrypting credentials.", captureSlot.captured.errorMessage) @@ -441,6 +445,9 @@ class Fido2ProviderProcessorTest { relyingPartyId = "mockRelyingPartyId-1", ) } returns mockFido2CredentialAutofillViews.asSuccess() + coEvery { + vaultRepository.getDecryptedFido2CredentialAutofillViews(any()) + } returns DecryptFido2CredentialAutofillViewResult.Success(mockFido2CredentialAutofillViews) every { intentManager.createFido2GetCredentialPendingIntent( action = "com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY", @@ -458,22 +465,23 @@ class Fido2ProviderProcessorTest { fido2Processor.processGetCredentialRequest(request, cancellationSignal, callback) verify(exactly = 0) { callback.onError(any()) } - verify(exactly = 1) { - callback.onResult(any()) - intentManager.createFido2GetCredentialPendingIntent( - action = "com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY", - credentialId = mockFido2CredentialAutofillViews.first().credentialId.toString(), - cipherId = mockFido2CredentialAutofillViews.first().cipherId, - requestCode = any(), - ) - } - coVerify(exactly = 1) { - vaultRepository.silentlyDiscoverCredentials( - userId = DEFAULT_USER_STATE.activeUserId, - fido2CredentialStore = fido2CredentialStore, - relyingPartyId = "mockRelyingPartyId-1", - ) - } + // TODO: [PM-9515] Uncomment when SDK bug is fixed. + // verify(exactly = 1) { + // callback.onResult(any()) + // intentManager.createFido2GetCredentialPendingIntent( + // action = "com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY", + // credentialId = mockFido2CredentialAutofillViews.first().credentialId.toString(), + // cipherId = mockFido2CredentialAutofillViews.first().cipherId, + // requestCode = any(), + // ) + // } + // coVerify(exactly = 1) { + // vaultRepository.silentlyDiscoverCredentials( + // userId = DEFAULT_USER_STATE.activeUserId, + // fido2CredentialStore = fido2CredentialStore, + // relyingPartyId = "mockRelyingPartyId-1", + // ) + // } assertEquals(1, captureSlot.captured.credentialEntries.size) assertEquals(mockPublicKeyCredentialEntry, captureSlot.captured.credentialEntries.first()) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/Fido2CredentialViewUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/Fido2CredentialViewUtil.kt index 32c075330e..e5b5503b66 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/Fido2CredentialViewUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/Fido2CredentialViewUtil.kt @@ -31,7 +31,7 @@ fun createMockFido2CredentialAutofillView( ): Fido2CredentialAutofillView = Fido2CredentialAutofillView( credentialId = "mockCredentialId-$number".toByteArray(), cipherId = "mockCipherId-$number", - rpId = "mockRpId-$number", + rpId = "mockRelyingPartyId-$number", userNameForUi = "mockUserNameForUi-$number", userHandle = "mockUserHandle-$number".toByteArray(), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/PasskeyAssertionOptionsTestHelpers.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/PasskeyAssertionOptionsTestHelpers.kt index d8d8183ecc..6f6448f69d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/PasskeyAssertionOptionsTestHelpers.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/PasskeyAssertionOptionsTestHelpers.kt @@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.UserVerificationRequirement */ fun createMockPasskeyAssertionOptions( number: Int, + userVerificationRequirement: UserVerificationRequirement? = + UserVerificationRequirement.PREFERRED, ) = PasskeyAssertionOptions( challenge = "mockChallenge-$number", allowCredentials = listOf( @@ -20,5 +22,5 @@ fun createMockPasskeyAssertionOptions( ), ), relyingPartyId = "mockRelyingPartyId-$number", - userVerification = UserVerificationRequirement.PREFERRED, + userVerification = userVerificationRequirement, ) 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 ed3f5ccff5..eb83e9b43a 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,6 +16,7 @@ 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.Fido2CredentialAssertionResult 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 @@ -87,6 +88,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { } private val fido2CompletionManager: Fido2CompletionManager = mockk { every { completeFido2Registration(any()) } just runs + every { completeFido2Assertion(any()) } just runs } private val biometricsManager: BiometricsManager = mockk() private val mutableEventFlow = bufferedMutableSharedFlow() @@ -1746,6 +1748,31 @@ class VaultItemListingScreenTest : BaseComposeTest() { } } + @Test + fun `fido2 error dialog should display and function according to state`() { + val dialogMessage = "Passkey error message" + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule.onNodeWithText(dialogMessage).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Fido2OperationFail( + title = R.string.an_error_has_occurred.asText(), + message = dialogMessage.asText(), + ), + ) + } + + composeTestRule + .onAllNodesWithText(text = "Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction(VaultItemListingsAction.DismissFido2ErrorDialogClick) + } + } + @Test fun `CompleteFido2Registration event should call Fido2CompletionManager with result`() { val result = Fido2RegisterCredentialResult.Success("mockResponse") @@ -1755,6 +1782,15 @@ class VaultItemListingScreenTest : BaseComposeTest() { } } + @Test + fun `CompleteFido2Assertion event should call Fido2CompletionManager with result`() { + val result = Fido2CredentialAssertionResult.Success("mockResponse") + mutableEventFlow.tryEmit(VaultItemListingEvent.CompleteFido2Assertion(result)) + verify { + fido2CompletionManager.completeFido2Assertion(result) + } + } + @Test fun `Fido2UserVerification event should perform user verification when it is supported`() { every { 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 95af83542b..0815fc70c2 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 @@ -13,10 +13,12 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult 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.Fido2CredentialAssertionResult 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.UserVerificationRequirement +import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialAssertionRequest 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 @@ -53,6 +55,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.createMockPasskeyAssertionOptions import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAttestationOptions import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.createMockDisplayItemForCipher @@ -360,7 +363,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { viewModel.trySendAction(VaultItemListingsAction.ItemClick(cipherView.id.orEmpty())) assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -391,7 +394,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { viewModel.trySendAction(VaultItemListingsAction.ItemClick(cipherView.id.orEmpty())) assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -1877,7 +1880,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { val viewModel = createVaultItemListingViewModel() assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( R.string.an_error_has_occurred.asText(), R.string.generic_error_message.asText(), ), @@ -1908,7 +1911,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { val viewModel = createVaultItemListingViewModel() assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( R.string.an_error_has_occurred.asText(), R.string.passkey_operation_failed_because_browser_is_not_privileged.asText(), ), @@ -1939,7 +1942,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { val viewModel = createVaultItemListingViewModel() assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( R.string.an_error_has_occurred.asText(), R.string.passkey_operation_failed_because_browser_signature_does_not_match.asText(), ), @@ -1970,7 +1973,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { val viewModel = createVaultItemListingViewModel() assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( R.string.an_error_has_occurred.asText(), R.string.passkeys_not_supported_for_this_app.asText(), ), @@ -2001,7 +2004,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { val viewModel = createVaultItemListingViewModel() assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( R.string.an_error_has_occurred.asText(), R.string.passkey_operation_failed_because_app_not_found_in_asset_links.asText(), ), @@ -2032,7 +2035,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { val viewModel = createVaultItemListingViewModel() assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( R.string.an_error_has_occurred.asText(), R.string.passkey_operation_failed_because_of_missing_asset_links.asText(), ), @@ -2063,7 +2066,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { val viewModel = createVaultItemListingViewModel() assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( R.string.an_error_has_occurred.asText(), R.string.passkey_operation_failed_because_app_could_not_be_verified.asText(), ), @@ -2148,10 +2151,13 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `DismissFido2ErrorDialogClick should clear the dialog state then complete FIDO 2 create`() = + fun `DismissFido2ErrorDialogClick should clear the dialog state then complete FIDO 2 registration based on state`() = runTest { + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + createMockFido2CredentialRequest(number = 1), + ) val viewModel = createVaultItemListingViewModel() - viewModel.trySendAction(VaultItemListingsAction.DismissFido2CreationErrorDialogClick) + viewModel.trySendAction(VaultItemListingsAction.DismissFido2ErrorDialogClick) viewModel.eventFlow.test { assertNull(viewModel.stateFlow.value.dialogState) assertEquals( @@ -2163,6 +2169,515 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `DismissFido2ErrorDialogClick should clear dialog state then complete FIDO 2 assertion with error when assertion request is not null`() = + runTest { + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + createMockFido2CredentialAssertionRequest(number = 1), + ) + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf( + createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ), + ) + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction(VaultItemListingsAction.DismissFido2ErrorDialogClick) + viewModel.eventFlow.test { + assertEquals( + VaultItemListingEvent.CompleteFido2Assertion( + result = Fido2CredentialAssertionResult.Error, + ), + awaitItem(), + ) + assertNull(viewModel.stateFlow.value.dialogState) + } + } + + @Suppress("MaxLineLength") + @Test + fun `DismissFido2ErrorDialogClick should show general error dialog when no FIDO 2 request is present`() = + runTest { + specialCircumstanceManager.specialCircumstance = null + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction(VaultItemListingsAction.DismissFido2ErrorDialogClick) + assertEquals( + VaultItemListingState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `Fido2AssertionRequest should display loading dialog then request user verification when user is not verified and verification is REQUIRED`() = + runTest { + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + .copy(cipherId = "mockId-1") + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + val mockCipherView = createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + mockAssertionRequest, + ) + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns createMockPasskeyAssertionOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.REQUIRED, + ) + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf( + createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ), + ) + + val viewModel = createVaultItemListingViewModel() + viewModel.eventFlow.test { + assertEquals( + VaultItemListingState.DialogState.Loading(R.string.loading.asText()), + viewModel.stateFlow.value.dialogState, + ) + verify { fido2CredentialManager.isUserVerified } + assertEquals( + VaultItemListingEvent.Fido2UserVerification( + isRequired = true, + selectedCipherView = mockCipherView, + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `Fido2AssertionRequest should display loading dialog then request user verification when user is not verified and verification is PREFERED`() = + runTest { + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + .copy(cipherId = "mockId-1") + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + val mockCipherView = createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + mockAssertionRequest, + ) + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns createMockPasskeyAssertionOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.PREFERRED, + ) + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf( + createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ), + ) + + val viewModel = createVaultItemListingViewModel() + viewModel.eventFlow.test { + assertEquals( + VaultItemListingState.DialogState.Loading(R.string.loading.asText()), + viewModel.stateFlow.value.dialogState, + ) + verify { fido2CredentialManager.isUserVerified } + assertEquals( + VaultItemListingEvent.Fido2UserVerification( + isRequired = false, + selectedCipherView = mockCipherView, + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `Fido2AssertionRequest should skip user verification when user is not verified and verification is DISCOURAGED`() = + runTest { + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + .copy(cipherId = "mockId-1") + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + val mockCipherView = createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + mockAssertionRequest, + ) + every { authRepository.activeUserId } returns "activeUserId" + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns createMockPasskeyAssertionOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.DISCOURAGED, + ) + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf( + createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ), + ) + coEvery { + fido2CredentialManager.authenticateFido2Credential( + userId = "activeUserId", + request = mockAssertionRequest, + selectedCipherView = mockCipherView, + ) + } returns Fido2CredentialAssertionResult.Success(responseJson = "responseJson") + + createVaultItemListingViewModel() + + coVerify { + fido2CredentialManager.isUserVerified + fido2CredentialManager.authenticateFido2Credential( + userId = "activeUserId", + request = mockAssertionRequest, + selectedCipherView = mockCipherView, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `Fido2AssertionRequest should show error dialog when user is not verified and verification is null`() = + runTest { + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + .copy(cipherId = "mockId-1") + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + val mockCipherView = createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + mockAssertionRequest, + ) + every { authRepository.activeUserId } returns "activeUserId" + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns createMockPasskeyAssertionOptions( + number = 1, + userVerificationRequirement = null, + ) + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf( + createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ), + ) + coEvery { + fido2CredentialManager.authenticateFido2Credential( + userId = "activeUserId", + request = mockAssertionRequest, + selectedCipherView = mockCipherView, + ) + } returns Fido2CredentialAssertionResult.Success(responseJson = "responseJson") + + val viewModel = createVaultItemListingViewModel() + viewModel.eventFlow.test { + assertEquals( + VaultItemListingState.DialogState.Fido2OperationFail( + 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, + ) + verify { fido2CredentialManager.isUserVerified } + coVerify(exactly = 0) { + fido2CredentialManager.authenticateFido2Credential( + userId = "activeUserId", + request = mockAssertionRequest, + selectedCipherView = mockCipherView, + ) + } + } + } + + @Suppress("MaxLineLength") + @Test + fun `Fido2AssertionRequest should show error dialog when assertion options are null`() = + runTest { + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + .copy(cipherId = "mockId-1") + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + mockAssertionRequest, + ) + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf( + createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ), + ) + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns null + + val viewModel = createVaultItemListingViewModel() + assertEquals( + VaultItemListingState.DialogState.Fido2OperationFail( + 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, + ) + verify(exactly = 0) { fido2CredentialManager.isUserVerified } + } + + @Suppress("MaxLineLength") + @Test + fun `Fido2AssertionRequest should observe vault data when request does not contain a cipherId`() = + runTest { + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + .copy(cipherId = null) + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + mockAssertionRequest, + ) + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf( + createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ), + ) + + createVaultItemListingViewModel() + + verify { vaultRepository.vaultDataStateFlow } + verify(exactly = 0) { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + requestJson = mockAssertionRequest.requestJson, + ) + } + } + + @Test + fun `Fido2AssertionRequest should show error dialog when cipher state flow data is null`() = + runTest { + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + mockAssertionRequest, + ) + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns null + + val viewModel = createVaultItemListingViewModel() + assertEquals( + VaultItemListingState.DialogState.Fido2OperationFail( + 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 `Fido2AssertionRequest should show error dialog when cipher state flow data has no matching cipher`() = + runTest { + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + mockAssertionRequest, + ) + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf( + createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ), + ) + + val viewModel = createVaultItemListingViewModel() + assertEquals( + VaultItemListingState.DialogState.Fido2OperationFail( + 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 `Fido2AssertionRequest should skip user verification when user is verified`() = runTest { + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + .copy(cipherId = "mockId-1") + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + val mockCipherView = createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + mockAssertionRequest, + ) + every { fido2CredentialManager.isUserVerified } returns true + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns createMockPasskeyAssertionOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.PREFERRED, + ) + coEvery { + fido2CredentialManager.authenticateFido2Credential( + userId = "activeUserId", + request = mockAssertionRequest, + selectedCipherView = mockCipherView, + ) + } returns Fido2CredentialAssertionResult.Success(responseJson = "responseJson") + + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf( + createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ), + ) + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns createMockPasskeyAssertionOptions(number = 1) + every { authRepository.activeUserId } returns "activeUserId" + + createVaultItemListingViewModel() + + coVerify { + fido2CredentialManager.isUserVerified + fido2CredentialManager.authenticateFido2Credential( + userId = any(), + request = any(), + selectedCipherView = any(), + ) + } + } + + @Test + fun `Fido2AssertionRequest should show error dialog when active user id is null`() = runTest { + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + .copy(cipherId = "mockId-1") + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + mockAssertionRequest, + ) + every { fido2CredentialManager.isUserVerified } returns true + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns createMockPasskeyAssertionOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.PREFERRED, + ) + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf( + createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ), + ) + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns createMockPasskeyAssertionOptions(number = 1) + every { authRepository.activeUserId } returns null + + val viewModel = createVaultItemListingViewModel() + + coVerify(exactly = 0) { + fido2CredentialManager.authenticateFido2Credential( + userId = any(), + request = any(), + selectedCipherView = any(), + ) + } + + assertEquals( + VaultItemListingState.DialogState.Fido2OperationFail( + 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 `UserVerificationLockout should display Fido2ErrorDialog and set isUserVerified to false`() { @@ -2171,7 +2686,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { verify { fido2CredentialManager.isUserVerified = false } assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), ), @@ -2205,7 +2720,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { verify { fido2CredentialManager.isUserVerified = false } assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -2237,7 +2752,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), ), @@ -2275,7 +2790,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), ), @@ -2298,7 +2813,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -2309,7 +2824,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationSuccess should set isUserVerified to true, and register FIDO 2 credential when registration result is received`() = + fun `UserVerificationSuccess should set isUserVerified to true, and register FIDO 2 credential when verification result is received`() = runTest { val mockRequest = createMockFido2CredentialRequest(number = 1) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( @@ -2342,6 +2857,63 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `UserVerificationSuccess should set isUserVerified to true, and authenticate FIDO 2 credential when verification result is received`() = + runTest { + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + .copy(cipherId = "mockId-1") + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + fido2AssertionRequest = mockAssertionRequest, + ) + + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf( + createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + ), + ) + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns createMockPasskeyAssertionOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.PREFERRED, + ) + coEvery { + fido2CredentialManager.authenticateFido2Credential( + any(), + any(), + any(), + ) + } returns Fido2CredentialAssertionResult.Success( + responseJson = "mockResponse", + ) + + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationSuccess( + selectedCipherView = createMockCipherView(number = 1), + ), + ) + + coVerify { + fido2CredentialManager.isUserVerified = true + fido2CredentialManager.authenticateFido2Credential( + userId = DEFAULT_ACCOUNT.userId, + request = mockAssertionRequest, + selectedCipherView = any(), + ) + } + } + @Test fun `UserVerificationNotSupported should display Fido2CreationFail when no cipher id found`() { val viewModel = createVaultItemListingViewModel() @@ -2354,7 +2926,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { verify { fido2CredentialManager.isUserVerified = false } assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -2378,7 +2950,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { verify { fido2CredentialManager.isUserVerified = false } assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -2488,7 +3060,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -2549,7 +3121,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -2579,7 +3151,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -2659,7 +3231,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -2720,7 +3292,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -2749,7 +3321,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -2898,7 +3470,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( title = R.string.an_error_has_occurred.asText(), message = R.string.passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -2965,7 +3537,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( + VaultItemListingState.DialogState.Fido2OperationFail( R.string.an_error_has_occurred.asText(), R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt index 7c95dd68a9..48ee8b2261 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt @@ -393,13 +393,13 @@ class VaultItemListingDataExtensionsTest { folderViewList = listOf(), sendViewList = listOf(), ).toViewState( - vaultFilterType = VaultFilterType.AllVaults, itemListingType = VaultItemListingState.ItemListingType.Vault.Folder("mockId-1"), - isIconLoadingDisabled = false, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, autofillSelectionData = null, fido2CreationData = null, - hasMasterPassword = true, fido2CredentialAutofillViews = null, isPremiumUser = true, ) @@ -481,16 +481,16 @@ class VaultItemListingDataExtensionsTest { folderViewList = listOf(), sendViewList = listOf(), ).toViewState( - vaultFilterType = VaultFilterType.AllVaults, itemListingType = VaultItemListingState.ItemListingType.Vault.Folder("mockId-1"), - isIconLoadingDisabled = false, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, autofillSelectionData = AutofillSelectionData( type = AutofillSelectionData.Type.LOGIN, uri = null, ), fido2CreationData = null, - hasMasterPassword = true, fido2CredentialAutofillViews = fido2CredentialAutofillViews, isPremiumUser = true, ) @@ -550,13 +550,13 @@ class VaultItemListingDataExtensionsTest { buttonText = R.string.add_an_item.asText(), ), vaultData.toViewState( - vaultFilterType = VaultFilterType.AllVaults, itemListingType = VaultItemListingState.ItemListingType.Vault.Trash, - isIconLoadingDisabled = false, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, autofillSelectionData = null, fido2CreationData = null, - hasMasterPassword = true, fido2CredentialAutofillViews = null, isPremiumUser = true, ), @@ -570,15 +570,15 @@ class VaultItemListingDataExtensionsTest { buttonText = R.string.add_an_item.asText(), ), vaultData.toViewState( - vaultFilterType = VaultFilterType.AllVaults, itemListingType = VaultItemListingState.ItemListingType.Vault.Folder( folderId = "folderId", ), - isIconLoadingDisabled = false, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, autofillSelectionData = null, fido2CreationData = null, - hasMasterPassword = true, fido2CredentialAutofillViews = null, isPremiumUser = true, ), @@ -592,13 +592,13 @@ class VaultItemListingDataExtensionsTest { buttonText = R.string.add_an_item.asText(), ), vaultData.toViewState( - vaultFilterType = VaultFilterType.AllVaults, itemListingType = VaultItemListingState.ItemListingType.Vault.Login, - isIconLoadingDisabled = false, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, autofillSelectionData = null, fido2CreationData = null, - hasMasterPassword = true, fido2CredentialAutofillViews = null, isPremiumUser = true, ), @@ -612,16 +612,16 @@ class VaultItemListingDataExtensionsTest { buttonText = R.string.add_an_item.asText(), ), vaultData.toViewState( - vaultFilterType = VaultFilterType.AllVaults, itemListingType = VaultItemListingState.ItemListingType.Vault.Login, - isIconLoadingDisabled = false, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, autofillSelectionData = AutofillSelectionData( type = AutofillSelectionData.Type.LOGIN, uri = "https://www.test.com", ), fido2CreationData = null, - hasMasterPassword = true, fido2CredentialAutofillViews = null, isPremiumUser = true, ), @@ -635,10 +635,11 @@ class VaultItemListingDataExtensionsTest { buttonText = R.string.save_passkey_as_new_login.asText(), ), vaultData.toViewState( - vaultFilterType = VaultFilterType.AllVaults, itemListingType = VaultItemListingState.ItemListingType.Vault.Login, - isIconLoadingDisabled = false, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, autofillSelectionData = null, fido2CreationData = Fido2CredentialRequest( userId = "", @@ -647,7 +648,6 @@ class VaultItemListingDataExtensionsTest { signingInfo = SigningInfo(), origin = "https://www.test.com", ), - hasMasterPassword = true, fido2CredentialAutofillViews = null, isPremiumUser = true, ), @@ -783,13 +783,13 @@ class VaultItemListingDataExtensionsTest { ) val actual = vaultData.toViewState( - isIconLoadingDisabled = false, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - autofillSelectionData = null, itemListingType = VaultItemListingState.ItemListingType.Vault.Folder("1"), vaultFilterType = VaultFilterType.AllVaults, - fido2CreationData = null, hasMasterPassword = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + autofillSelectionData = null, + fido2CreationData = null, fido2CredentialAutofillViews = null, isPremiumUser = true, ) @@ -826,13 +826,13 @@ class VaultItemListingDataExtensionsTest { ) val actual = vaultData.toViewState( - isIconLoadingDisabled = false, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - autofillSelectionData = null, itemListingType = VaultItemListingState.ItemListingType.Vault.Collection("mockId-1"), vaultFilterType = VaultFilterType.AllVaults, - fido2CreationData = null, hasMasterPassword = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + autofillSelectionData = null, + fido2CreationData = null, fido2CredentialAutofillViews = null, isPremiumUser = true, )