mirror of
https://github.com/bitwarden/android.git
synced 2025-03-16 03:08:50 +03:00
PM-9684: Verify with master password on item listing (#3585)
This commit is contained in:
parent
8a381d8682
commit
ee87d8ada8
7 changed files with 627 additions and 46 deletions
|
@ -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].
|
||||
*/
|
||||
|
|
|
@ -41,6 +41,8 @@ class Fido2CredentialManagerImpl(
|
|||
|
||||
override var isUserVerified: Boolean = false
|
||||
|
||||
override var authenticationAttempts: Int = 0
|
||||
|
||||
override suspend fun registerFido2Credential(
|
||||
userId: String,
|
||||
fido2CredentialRequest: Fido2CredentialRequest,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Reference in a new issue