PM-9684: Verify with master password on item listing (#3585)

This commit is contained in:
Shannon Draeker 2024-07-19 15:20:55 -06:00 committed by GitHub
parent 8a381d8682
commit ee87d8ada8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 627 additions and 46 deletions

View file

@ -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].
*/

View file

@ -41,6 +41,8 @@ class Fido2CredentialManagerImpl(
override var isUserVerified: Boolean = false
override var authenticationAttempts: Int = 0
override suspend fun registerFido2Credential(
userId: String,
fido2CredentialRequest: Fido2CredentialRequest,

View file

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

View file

@ -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<VaultData>) {
@ -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.
*/

View file

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

View file

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

View file

@ -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<OrganizationEventManager> {
@ -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(