diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchContent.kt index 81c34da94f..1f28f49d82 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchContent.kt @@ -121,10 +121,13 @@ fun SearchContent( supportingLabelTestTag = it.subtitleTestTag, optionsTestTag = it.overflowTestTag, onClick = { - if (it.isTotp && it.shouldDisplayMasterPasswordReprompt) { - masterPasswordRepromptData = MasterPasswordRepromptData.Totp(it.id) - } else if (it.autofillSelectionOptions.isNotEmpty()) { + if (it.autofillSelectionOptions.isNotEmpty()) { autofillSelectionOptionsItem = it + } else if (it.shouldDisplayMasterPasswordReprompt) { + masterPasswordRepromptData = MasterPasswordRepromptData.ViewItem( + cipherId = it.id, + itemType = it.itemType, + ) } else { searchHandlers.onItemClick(it.id, it.itemType) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt index dda34c2201..8e5cb7c893 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt @@ -643,11 +643,11 @@ class SearchViewModel @Inject constructor( ) } - is MasterPasswordRepromptData.Totp -> { + is MasterPasswordRepromptData.ViewItem -> { trySendAction( action = SearchAction.ItemClick( itemId = data.cipherId, - itemType = SearchState.DisplayItem.ItemType.Vault(type = CipherType.LOGIN), + itemType = data.itemType, ), ) } @@ -761,7 +761,6 @@ class SearchViewModel @Inject constructor( baseIconUrl = state.baseIconUrl, isIconLoadingDisabled = state.isIconLoadingDisabled, isAutofill = state.isAutofill, - isTotp = state.isTotp, isPremiumUser = state.isPremium, ) } @@ -909,7 +908,6 @@ data class SearchState( val overflowOptions: List, val overflowTestTag: String?, val autofillSelectionOptions: List, - val isTotp: Boolean, val shouldDisplayMasterPasswordReprompt: Boolean, val itemType: ItemType, ) : Parcelable { @@ -1296,14 +1294,6 @@ sealed class MasterPasswordRepromptData : Parcelable { val cipherId: String, ) : MasterPasswordRepromptData() - /** - * Autofill was selected. - */ - @Parcelize - data class Totp( - val cipherId: String, - ) : MasterPasswordRepromptData() - /** * A cipher overflow menu item action was selected. */ @@ -1311,4 +1301,13 @@ sealed class MasterPasswordRepromptData : Parcelable { data class OverflowItem( val action: ListingItemOverflowAction.VaultAction, ) : MasterPasswordRepromptData() + + /** + * Item was selected to be viewed. + */ + @Parcelize + data class ViewItem( + val cipherId: String, + val itemType: SearchState.DisplayItem.ItemType, + ) : MasterPasswordRepromptData() } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt index bf23175879..528c8bc50d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt @@ -151,7 +151,6 @@ fun List.toViewState( hasMasterPassword: Boolean, isIconLoadingDisabled: Boolean, isAutofill: Boolean, - isTotp: Boolean, isPremiumUser: Boolean, ): SearchState.ViewState = when { @@ -163,7 +162,6 @@ fun List.toViewState( hasMasterPassword = hasMasterPassword, isIconLoadingDisabled = isIconLoadingDisabled, isAutofill = isAutofill, - isTotp = isTotp, isPremiumUser = isPremiumUser, ) .sortAlphabetically(), @@ -177,13 +175,11 @@ fun List.toViewState( } } -@Suppress("LongParameterList") private fun List.toDisplayItemList( baseIconUrl: String, hasMasterPassword: Boolean, isIconLoadingDisabled: Boolean, isAutofill: Boolean, - isTotp: Boolean, isPremiumUser: Boolean, ): List = this.map { @@ -192,18 +188,15 @@ private fun List.toDisplayItemList( hasMasterPassword = hasMasterPassword, isIconLoadingDisabled = isIconLoadingDisabled, isAutofill = isAutofill, - isTotp = isTotp, isPremiumUser = isPremiumUser, ) } -@Suppress("LongParameterList") private fun CipherView.toDisplayItem( baseIconUrl: String, hasMasterPassword: Boolean, isIconLoadingDisabled: Boolean, isAutofill: Boolean, - isTotp: Boolean, isPremiumUser: Boolean, ): SearchState.DisplayItem = SearchState.DisplayItem( @@ -231,8 +224,8 @@ private fun CipherView.toDisplayItem( .filter { this.login != null || (it != AutofillSelectionOption.AUTOFILL_AND_SAVE) }, - isTotp = isTotp, - shouldDisplayMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, + shouldDisplayMasterPasswordReprompt = hasMasterPassword && + reprompt == CipherRepromptType.PASSWORD, itemType = SearchState.DisplayItem.ItemType.Vault(type = this.type), ) @@ -373,7 +366,6 @@ private fun SendView.toDisplayItem( overflowTestTag = "SendOptionsButton", totpCode = null, autofillSelectionOptions = emptyList(), - isTotp = false, shouldDisplayMasterPasswordReprompt = false, itemType = SearchState.DisplayItem.ItemType.Sends(type = this.type), ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index ed572dd657..a5ac37d832 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -34,7 +34,6 @@ import com.x8bit.bitwarden.ui.platform.components.content.BitwardenErrorContent import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent 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.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager @@ -127,16 +126,6 @@ fun VaultItemScreen( onDismissRequest = remember(viewModel) { { viewModel.trySendAction(VaultItemAction.Common.DismissDialogClick) } }, - onSubmitMasterPassword = remember(viewModel) { - { masterPassword, action -> - viewModel.trySendAction( - VaultItemAction.Common.MasterPasswordSubmit( - masterPassword = masterPassword, - action = action, - ), - ) - } - }, onConfirmDeleteClick = remember(viewModel) { { viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick) } }, @@ -288,7 +277,6 @@ private fun VaultItemDialogs( dialog: VaultItemState.DialogState?, onDismissRequest: () -> Unit, onConfirmDeleteClick: () -> Unit, - onSubmitMasterPassword: (masterPassword: String, action: PasswordRepromptAction) -> Unit, onConfirmCloneWithoutFido2Credential: () -> Unit, onConfirmRestoreAction: () -> Unit, ) { @@ -304,13 +292,6 @@ private fun VaultItemDialogs( text = dialog.message(), ) - is VaultItemState.DialogState.MasterPasswordDialog -> { - BitwardenMasterPasswordDialog( - onConfirmClick = { onSubmitMasterPassword(it, dialog.action) }, - onDismissRequest = onDismissRequest, - ) - } - is VaultItemState.DialogState.DeleteConfirmationPrompt -> { BitwardenTwoButtonDialog( title = stringResource(id = R.string.delete), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index 29e5431c9e..a5eb95676b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -17,7 +17,6 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState -import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager @@ -234,7 +233,6 @@ class VaultItemViewModel @Inject constructor( is VaultItemAction.Common.CloseClick -> handleCloseClick() is VaultItemAction.Common.DismissDialogClick -> handleDismissDialogClick() is VaultItemAction.Common.EditClick -> handleEditClick() - is VaultItemAction.Common.MasterPasswordSubmit -> handleMasterPasswordSubmit(action) is VaultItemAction.Common.RefreshClick -> handleRefreshClick() is VaultItemAction.Common.CopyCustomHiddenFieldClick -> { handleCopyCustomHiddenFieldClick(action) @@ -286,50 +284,21 @@ class VaultItemViewModel @Inject constructor( } private fun handleDeleteClick() { - onContent { content -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.DeleteClick, - ), - ) - return@onContent - } else { - updateDialogState( - VaultItemState - .DialogState - .DeleteConfirmationPrompt(state.deletionConfirmationText), - ) - } - } + updateDialogState( + dialog = VaultItemState.DialogState.DeleteConfirmationPrompt( + message = state.deletionConfirmationText, + ), + ) } private fun handleEditClick() { - onContent { content -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.EditClick, - ), - ) - return@onContent - } - sendEvent( - VaultItemEvent.NavigateToAddEdit( - itemId = state.vaultItemId, - isClone = false, - type = state.cipherType, - ), - ) - } - } - - private fun handleMasterPasswordSubmit(action: VaultItemAction.Common.MasterPasswordSubmit) { - updateDialogState(VaultItemState.DialogState.Loading(R.string.loading.asText())) - viewModelScope.launch { - val result = authRepository.validatePassword(action.masterPassword) - sendAction(VaultItemAction.Internal.ValidatePasswordReceive(result, action.action)) - } + sendEvent( + VaultItemEvent.NavigateToAddEdit( + itemId = state.vaultItemId, + isClone = false, + type = state.cipherType, + ), + ) } private fun handleRefreshClick() { @@ -341,14 +310,6 @@ class VaultItemViewModel @Inject constructor( action: VaultItemAction.Common.CopyCustomHiddenFieldClick, ) { onContent { content -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.CopyClick(action.field), - ), - ) - return@onContent - } clipboardManager.setText(text = action.field) organizationEventManager.trackEvent( event = OrganizationEvent.CipherClientCopiedHiddenField( @@ -368,17 +329,6 @@ class VaultItemViewModel @Inject constructor( action: VaultItemAction.Common.HiddenFieldVisibilityClicked, ) { onContent { content -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.ViewHiddenFieldClicked( - field = action.field, - isVisible = action.isVisible, - ), - ), - ) - return@onContent - } mutableStateFlow.update { currentState -> currentState.copy( viewState = content.copy( @@ -408,17 +358,6 @@ class VaultItemViewModel @Inject constructor( action: VaultItemAction.Common.AttachmentDownloadClick, ) { onContent { content -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.AttachmentDownloadClick( - attachment = action.attachment, - ), - ), - ) - return@onContent - } - updateDialogState(VaultItemState.DialogState.Loading(R.string.downloading.asText())) viewModelScope.launch { @@ -472,36 +411,19 @@ class VaultItemViewModel @Inject constructor( } private fun handleAttachmentsClick() { - onContent { content -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.AttachmentsClick, - ), - ) - return@onContent - } - sendEvent(VaultItemEvent.NavigateToAttachments(itemId = state.vaultItemId)) - } + sendEvent(VaultItemEvent.NavigateToAttachments(itemId = state.vaultItemId)) } - @Suppress("MaxLineLength") private fun handleCloneClick() { onContent { content -> if (content.common.requiresCloneConfirmation) { updateDialogState( + @Suppress("MaxLineLength") VaultItemState.DialogState.Fido2CredentialCannotBeCopiedConfirmationPrompt( message = R.string.the_passkey_will_not_be_copied_to_the_cloned_item_do_you_want_to_continue_cloning_this_item.asText(), ), ) return@onContent - } else if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.CloneClick, - ), - ) - return@onContent } sendEvent( event = VaultItemEvent.NavigateToAddEdit( @@ -531,17 +453,7 @@ class VaultItemViewModel @Inject constructor( } private fun handleMoveToOrganizationClick() { - onContent { content -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.MoveToOrganizationClick, - ), - ) - return@onContent - } - sendEvent(VaultItemEvent.NavigateToMoveToOrganization(itemId = state.vaultItemId)) - } + sendEvent(VaultItemEvent.NavigateToMoveToOrganization(itemId = state.vaultItemId)) } private fun handleCollectionsClick() { @@ -676,17 +588,8 @@ class VaultItemViewModel @Inject constructor( private fun handleCopyPasswordClick() { onLoginContent { content, login -> - val password = requireNotNull(login.passwordData?.password) - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.CopyClick(value = password), - ), - ) - return@onLoginContent - } clipboardManager.setText( - text = password, + text = requireNotNull(login.passwordData).password, toastDescriptorOverride = R.string.password.asText(), ) organizationEventManager.trackEvent( @@ -729,33 +632,13 @@ class VaultItemViewModel @Inject constructor( } private fun handlePasswordHistoryClick() { - onContent { content -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.PasswordHistoryClick, - ), - ) - return@onContent - } - sendEvent(VaultItemEvent.NavigateToPasswordHistory(state.vaultItemId)) - } + sendEvent(VaultItemEvent.NavigateToPasswordHistory(itemId = state.vaultItemId)) } private fun handlePasswordVisibilityClicked( action: VaultItemAction.ItemType.Login.PasswordVisibilityClicked, ) { onLoginContent { content, login -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.ViewPasswordClick( - isVisible = action.isVisible, - ), - ), - ) - return@onLoginContent - } mutableStateFlow.update { currentState -> currentState.copy( viewState = content.copy( @@ -799,16 +682,6 @@ class VaultItemViewModel @Inject constructor( action: VaultItemAction.ItemType.Card.CodeVisibilityClick, ) { onCardContent { content, card -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.ViewCodeClick( - isVisible = action.isVisible, - ), - ), - ) - return@onCardContent - } mutableStateFlow.update { currentState -> currentState.copy( viewState = content.copy( @@ -832,17 +705,8 @@ class VaultItemViewModel @Inject constructor( private fun handleCopyNumberClick() { onCardContent { content, card -> - val number = requireNotNull(card.number).number - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.CopyClick(value = number), - ), - ) - return@onCardContent - } clipboardManager.setText( - text = number, + text = requireNotNull(card.number).number, toastDescriptorOverride = R.string.number.asText(), ) } @@ -850,17 +714,8 @@ class VaultItemViewModel @Inject constructor( private fun handleCopySecurityCodeClick() { onCardContent { content, card -> - val securityCode = requireNotNull(card.securityCode).code - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.CopyClick(value = securityCode), - ), - ) - return@onCardContent - } clipboardManager.setText( - text = securityCode, + text = requireNotNull(card.securityCode).code, toastDescriptorOverride = R.string.security_code.asText(), ) } @@ -870,16 +725,6 @@ class VaultItemViewModel @Inject constructor( action: VaultItemAction.ItemType.Card.NumberVisibilityClick, ) { onCardContent { content, card -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.ViewNumberClick( - isVisible = action.isVisible, - ), - ), - ) - return@onCardContent - } mutableStateFlow.update { currentState -> currentState.copy( viewState = content.copy( @@ -932,16 +777,6 @@ class VaultItemViewModel @Inject constructor( action: VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked, ) { onSshKeyContent { content, sshKey -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.ViewPrivateKeyClicked( - isVisible = action.isVisible, - ), - ), - ) - return@onSshKeyContent - } mutableStateFlow.update { currentState -> currentState.copy( viewState = content.copy( @@ -954,14 +789,6 @@ class VaultItemViewModel @Inject constructor( private fun handleCopyPrivateKeyClick() { onSshKeyContent { content, sshKey -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.CopyClick(value = sshKey.privateKey), - ), - ) - return@onSshKeyContent - } clipboardManager.setText( text = sshKey.privateKey, toastDescriptorOverride = R.string.private_key.asText(), @@ -1107,13 +934,8 @@ class VaultItemViewModel @Inject constructor( is VaultItemAction.Internal.CopyValue -> handleCopyValue(action) is VaultItemAction.Internal.PasswordBreachReceive -> handlePasswordBreachReceive(action) is VaultItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) - is VaultItemAction.Internal.ValidatePasswordReceive -> handleValidatePasswordReceive( - action, - ) - is VaultItemAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action) is VaultItemAction.Internal.RestoreCipherReceive -> handleRestoreCipherReceive(action) - is VaultItemAction.Internal.AttachmentDecryptReceive -> { handleAttachmentDecryptReceive(action) } @@ -1229,7 +1051,6 @@ class VaultItemViewModel @Inject constructor( ?.toViewState( previousState = state.viewState.asContentOrNull(), isPremiumUser = account.isPremium, - hasMasterPassword = account.hasMasterPassword, totpCodeItemData = this.data?.totpCodeItemData, canDelete = this.data?.canDelete == true, canRestore = this.data?.canRestore == true, @@ -1241,43 +1062,6 @@ class VaultItemViewModel @Inject constructor( ) ?: VaultItemState.ViewState.Error(message = errorText) - private fun handleValidatePasswordReceive( - action: VaultItemAction.Internal.ValidatePasswordReceive, - ) { - when (val result = action.result) { - is ValidatePasswordResult.Error -> { - updateDialogState( - VaultItemState.DialogState.Generic( - message = R.string.generic_error_message.asText(), - error = result.error, - ), - ) - } - - is ValidatePasswordResult.Success -> { - if (result.isValid) { - onContent { content -> - mutableStateFlow.update { - it.copy( - dialog = null, - viewState = content.copy( - common = content.common.copy(requiresReprompt = false), - ), - ) - } - trySendAction(action.repromptAction.vaultItemAction) - } - } else { - updateDialogState( - VaultItemState.DialogState.Generic( - message = R.string.invalid_master_password.asText(), - ), - ) - } - } - } - } - private fun handleDeleteCipherReceive(action: VaultItemAction.Internal.DeleteCipherReceive) { when (val result = action.result) { is DeleteCipherResult.Error -> { @@ -1367,17 +1151,7 @@ class VaultItemViewModel @Inject constructor( } private fun handleRestoreItemClicked() { - onContent { content -> - if (content.common.requiresReprompt) { - updateDialogState( - VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.RestoreItemClick, - ), - ) - } else { - updateDialogState(VaultItemState.DialogState.RestoreItemDialog) - } - } + updateDialogState(VaultItemState.DialogState.RestoreItemDialog) } private fun handleIsIconLoadingDisabledUpdateReceive( @@ -1589,8 +1363,6 @@ data class VaultItemState( * updated. * @property notes Contains general notes taken by the user. * @property customFields A list of custom fields that user has added. - * @property requiresReprompt Indicates if a master password prompt is required to view - * secure fields. * @property requiresCloneConfirmation Indicates user confirmation is required when * cloning a cipher. * @property currentCipher The cipher that is currently being viewed (nullable). @@ -1608,7 +1380,6 @@ data class VaultItemState( val lastUpdated: String, val notes: String?, val customFields: List, - val requiresReprompt: Boolean, val requiresCloneConfirmation: Boolean, @IgnoredOnParcel val currentCipher: CipherView? = null, @@ -1915,14 +1686,6 @@ data class VaultItemState( val message: Text, ) : DialogState() - /** - * Displays the master password dialog to the user. - */ - @Parcelize - data class MasterPasswordDialog( - val action: PasswordRepromptAction, - ) : DialogState() - /** * Displays the dialog for deleting the item to the user. */ @@ -2062,14 +1825,6 @@ sealed class VaultItemAction { */ data object EditClick : Common() - /** - * The user has submitted their master password. - */ - data class MasterPasswordSubmit( - val masterPassword: String, - val action: PasswordRepromptAction, - ) : Common() - /** * The user has clicked the refresh button. */ @@ -2337,14 +2092,6 @@ sealed class VaultItemAction { val vaultDataState: DataState, ) : Internal() - /** - * Indicates that the verify password result has been received. - */ - data class ValidatePasswordReceive( - val result: ValidatePasswordResult, - val repromptAction: PasswordRepromptAction, - ) : Internal() - /** * Indicates that the delete cipher result has been received. */ @@ -2384,175 +2131,3 @@ sealed class VaultItemAction { ) : Internal() } } - -/** - * Represents all the actions that can be taken after being prompted to a master password check. - */ -sealed class PasswordRepromptAction : Parcelable { - /** - * The Vault action that should be sent when password validation has completed. - */ - abstract val vaultItemAction: VaultItemAction - - /** - * Indicates that we should launch the [VaultItemAction.Common.EditClick] upon password - * validation. - */ - @Parcelize - data object EditClick : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.Common.EditClick - } - - /** - * Indicates that we should launch the [VaultItemAction.Common.PasswordHistoryClick] - * upon password validation. - */ - @Parcelize - data object PasswordHistoryClick : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.Common.PasswordHistoryClick - } - - /** - * Indicates that we should launch the [VaultItemAction.Common.AttachmentsClick] upon password - * validation. - */ - @Parcelize - data object AttachmentsClick : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.Common.AttachmentsClick - } - - /** - * Indicates an attachment download was clicked. - */ - @Parcelize - data class AttachmentDownloadClick( - val attachment: VaultItemState.ViewState.Content.Common.AttachmentItem, - ) : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.Common.AttachmentDownloadClick(attachment) - } - - /** - * Indicates that we should launch the [VaultItemAction.Common.CloneClick] upon password - * validation. - */ - @Parcelize - data object CloneClick : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.Common.CloneClick - } - - /** - * Indicates that we should launch the [VaultItemAction.Common.MoveToOrganizationClick] upon - * password validation. - */ - @Parcelize - data object MoveToOrganizationClick : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.Common.MoveToOrganizationClick - } - - /** - * Indicates that we should launch the [VaultItemAction.Common.ConfirmDeleteClick] upon - * password validation. - */ - @Parcelize - data object DeleteClick : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.Common.DeleteClick - } - - /** - * Indicates that we should launch the [VaultItemAction.Internal.CopyValue] upon password - * validation. - */ - @Parcelize - data class CopyClick( - val value: String, - ) : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.Internal.CopyValue( - value = value, - ) - } - - /** - * Indicates that we should launch the [VaultItemAction.ItemType.Card.CodeVisibilityClick] - * upon password validation. - */ - @Parcelize - data class ViewCodeClick( - val isVisible: Boolean, - ) : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.ItemType.Card.CodeVisibilityClick(isVisible = isVisible) - } - - /** - * Indicates that we should launch the [VaultItemAction.ItemType.Card.NumberVisibilityClick] - * upon password validation. - */ - @Parcelize - data class ViewNumberClick( - val isVisible: Boolean, - ) : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.ItemType.Card.NumberVisibilityClick(isVisible = isVisible) - } - - /** - * Indicates that we should launch the - * [VaultItemAction.ItemType.Login.PasswordVisibilityClicked] upon password validation. - */ - @Parcelize - data class ViewPasswordClick( - val isVisible: Boolean, - ) : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.ItemType.Login.PasswordVisibilityClicked( - isVisible = isVisible, - ) - } - - /** - * Indicates that we should launch the [VaultItemAction.Common.HiddenFieldVisibilityClicked] - * upon password validation. - */ - @Parcelize - data class ViewHiddenFieldClicked( - val field: VaultItemState.ViewState.Content.Common.Custom.HiddenField, - val isVisible: Boolean, - ) : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.Common.HiddenFieldVisibilityClicked( - field = this.field, - isVisible = this.isVisible, - ) - } - - /** - * Indicates that we should show the confirm restore - */ - @Parcelize - data object RestoreItemClick : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.Common.RestoreVaultItemClick - } - - /** - * Indicates that we should launch the - * [VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked] upon password validation. - */ - @Parcelize - data class ViewPrivateKeyClicked( - val isVisible: Boolean, - ) : PasswordRepromptAction() { - override val vaultItemAction: VaultItemAction - get() = VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked( - isVisible = isVisible, - ) - } -} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt index a98c3d39c4..10f8ddde8b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt @@ -7,7 +7,6 @@ import com.bitwarden.ui.platform.base.util.orZeroWidthSpace import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import com.bitwarden.vault.CardView -import com.bitwarden.vault.CipherRepromptType import com.bitwarden.vault.CipherType import com.bitwarden.vault.CipherView import com.bitwarden.vault.Fido2Credential @@ -41,7 +40,6 @@ private const val FIDO2_CREDENTIAL_CREATION_TIME_PATTERN: String = "h:mm a" fun CipherView.toViewState( previousState: VaultItemState.ViewState.Content?, isPremiumUser: Boolean, - hasMasterPassword: Boolean, totpCodeItemData: TotpCodeItemData?, clock: Clock = Clock.systemDefaultZone(), canDelete: Boolean, @@ -56,8 +54,6 @@ fun CipherView.toViewState( common = VaultItemState.ViewState.Content.Common( currentCipher = this, name = name, - requiresReprompt = (reprompt == CipherRepromptType.PASSWORD && hasMasterPassword) && - previousState?.common?.requiresReprompt != false, customFields = fields.orEmpty().map { fieldView -> fieldView.toCustomField( previousState = previousState diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt index 6936a32323..d4b23b8530 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt @@ -88,10 +88,7 @@ fun VaultItemListingContent( BitwardenMasterPasswordDialog( onConfirmClick = { password -> masterPasswordRepromptData = null - masterPasswordRepromptSubmit( - password, - data, - ) + masterPasswordRepromptSubmit(password, data) }, onDismissRequest = { masterPasswordRepromptData = null @@ -213,14 +210,15 @@ fun VaultItemListingContent( supportingLabelTestTag = it.subtitleTestTag, optionsTestTag = it.optionsTestTag, onClick = { - if (it.isTotp && it.shouldShowMasterPasswordReprompt) { - masterPasswordRepromptData = MasterPasswordRepromptData.Totp( - cipherId = it.id, - ) - } else if (it.isAutofill && it.shouldShowMasterPasswordReprompt) { + if (it.isAutofill && it.shouldShowMasterPasswordReprompt) { masterPasswordRepromptData = MasterPasswordRepromptData.Autofill( cipherId = it.id, ) + } else if (it.shouldShowMasterPasswordReprompt) { + masterPasswordRepromptData = MasterPasswordRepromptData.ViewItem( + id = it.id, + itemType = it.itemType, + ) } else { vaultItemClick(it.id, it.itemType) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 70f9503313..d5c1323b76 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -1468,13 +1468,8 @@ class VaultItemListingViewModel @Inject constructor( ) } - is MasterPasswordRepromptData.Totp -> { - sendEvent( - VaultItemListingEvent.NavigateToEditCipher( - cipherId = data.cipherId, - cipherType = VaultItemCipherType.LOGIN, - ), - ) + is MasterPasswordRepromptData.ViewItem -> { + trySendAction(VaultItemListingsAction.ItemClick(id = data.id, type = data.itemType)) } } } @@ -2340,7 +2335,6 @@ data class VaultItemListingState( val optionsTestTag: String, val isAutofill: Boolean, val isCredentialCreation: Boolean, - val isTotp: Boolean, val shouldShowMasterPasswordReprompt: Boolean, val itemType: ItemType, ) { @@ -2988,28 +2982,26 @@ sealed class VaultItemListingsAction { /** * Data tracking the type of request that triggered a master password reprompt. */ -sealed class MasterPasswordRepromptData : Parcelable { +sealed class MasterPasswordRepromptData { /** * Autofill was selected. */ - @Parcelize data class Autofill( val cipherId: String, ) : MasterPasswordRepromptData() - /** - * Totp was selected. - */ - @Parcelize - data class Totp( - val cipherId: String, - ) : MasterPasswordRepromptData() - /** * A cipher overflow menu item action was selected. */ - @Parcelize data class OverflowItem( val action: ListingItemOverflowAction.VaultAction, ) : MasterPasswordRepromptData() + + /** + * An item was selected to be viewed. + */ + data class ViewItem( + val id: String, + val itemType: VaultItemListingState.DisplayItem.ItemType, + ) : MasterPasswordRepromptData() } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/model/ListingItemOverflowAction.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/model/ListingItemOverflowAction.kt index 76794c738f..7aed942e7c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/model/ListingItemOverflowAction.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/model/ListingItemOverflowAction.kt @@ -94,9 +94,9 @@ sealed class ListingItemOverflowAction : Parcelable { data class ViewClick( val cipherId: String, val cipherType: CipherType, + override val requiresPasswordReprompt: Boolean, ) : VaultAction() { override val title: Text get() = R.string.view.asText() - override val requiresPasswordReprompt: Boolean get() = false } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index 0f835b3945..18376b42ef 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -141,7 +141,6 @@ fun VaultData.toViewState( isFido2Creation = createCredentialRequestData != null, fido2CredentialAutofillViews = fido2CredentialAutofillViews, isPremiumUser = isPremiumUser, - isTotp = totpData != null, ), displayFolderList = folderList.map { folderView -> VaultItemListingState.FolderDisplayItem( @@ -341,7 +340,6 @@ private fun List.toDisplayItemList( isFido2Creation: Boolean, fido2CredentialAutofillViews: List?, isPremiumUser: Boolean, - isTotp: Boolean, ): List = this.map { it.toDisplayItem( @@ -355,7 +353,6 @@ private fun List.toDisplayItemList( fido2CredentialAutofillView.cipherId == it.id }, isPremiumUser = isPremiumUser, - isTotp = isTotp, ) } @@ -379,7 +376,6 @@ private fun CipherView.toDisplayItem( isFido2Creation: Boolean, fido2CredentialAutofillView: Fido2CredentialAutofillView?, isPremiumUser: Boolean, - isTotp: Boolean, ): VaultItemListingState.DisplayItem = VaultItemListingState.DisplayItem( id = id.orEmpty(), @@ -407,7 +403,6 @@ private fun CipherView.toDisplayItem( optionsTestTag = "CipherOptionsButton", isAutofill = isAutofill, isCredentialCreation = isFido2Creation, - isTotp = isTotp, shouldShowMasterPasswordReprompt = (reprompt == CipherRepromptType.PASSWORD) && hasMasterPassword, itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = this.type), @@ -481,7 +476,6 @@ private fun SendView.toDisplayItem( isAutofill = false, shouldShowMasterPasswordReprompt = false, isCredentialCreation = false, - isTotp = false, itemType = VaultItemListingState.DisplayItem.ItemType.Sends(type = this.type), ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherViewExtensions.kt index eadedb7433..26006cac2d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherViewExtensions.kt @@ -22,6 +22,7 @@ fun CipherView.toOverflowActions( ListingItemOverflowAction.VaultAction.ViewClick( cipherId = cipherId, cipherType = this.type, + requiresPasswordReprompt = hasMasterPassword, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = cipherId, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt index be5433a6f9..e956d984fa 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt @@ -8,6 +8,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -17,6 +21,7 @@ import com.bitwarden.ui.platform.base.util.toListItemCardStyle import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.listitem.BitwardenGroupItem import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction @@ -34,9 +39,34 @@ private const val TRASH_TYPES_COUNT: Int = 1 fun VaultContent( state: VaultState.ViewState.Content, vaultHandlers: VaultHandlers, - onOverflowOptionClick: (action: ListingItemOverflowAction.VaultAction) -> Unit, modifier: Modifier = Modifier, ) { + // Handles the master password prompt for the row click + var masterPasswordRepromptItem by remember { + mutableStateOf(value = null) + } + masterPasswordRepromptItem?.let { action -> + BitwardenMasterPasswordDialog( + onConfirmClick = { password -> + masterPasswordRepromptItem = null + vaultHandlers.masterPasswordRepromptSubmit(action, password) + }, + onDismissRequest = { masterPasswordRepromptItem = null }, + ) + } + // Handles the master password prompt for the overflow clicks + var overflowMasterPasswordRepromptAction by remember { + mutableStateOf(value = null) + } + overflowMasterPasswordRepromptAction?.let { action -> + BitwardenMasterPasswordDialog( + onConfirmClick = { password -> + overflowMasterPasswordRepromptAction = null + vaultHandlers.overflowMasterPasswordRepromptSubmit(action, password) + }, + onDismissRequest = { overflowMasterPasswordRepromptAction = null }, + ) + } LazyColumn( modifier = modifier, ) { @@ -93,13 +123,19 @@ fun VaultContent( trailingLabelIcons = favoriteItem.extraIconList, label = favoriteItem.name(), supportingLabel = favoriteItem.supportingLabel?.invoke(), - onClick = { vaultHandlers.vaultItemClick(favoriteItem) }, + onClick = { + if (favoriteItem.shouldShowMasterPasswordReprompt) { + masterPasswordRepromptItem = favoriteItem + } else { + vaultHandlers.vaultItemClick(favoriteItem) + } + }, overflowOptions = favoriteItem.overflowOptions.toImmutableList(), onOverflowOptionClick = { action -> if (favoriteItem.shouldShowMasterPasswordReprompt && action.requiresPasswordReprompt ) { - onOverflowOptionClick(action) + overflowMasterPasswordRepromptAction = action } else { vaultHandlers.overflowOptionClick(action) } @@ -267,13 +303,19 @@ fun VaultContent( trailingLabelIcons = noFolderItem.extraIconList, label = noFolderItem.name(), supportingLabel = noFolderItem.supportingLabel?.invoke(), - onClick = { vaultHandlers.vaultItemClick(noFolderItem) }, + onClick = { + if (noFolderItem.shouldShowMasterPasswordReprompt) { + masterPasswordRepromptItem = noFolderItem + } else { + vaultHandlers.vaultItemClick(noFolderItem) + } + }, overflowOptions = noFolderItem.overflowOptions.toImmutableList(), onOverflowOptionClick = { action -> if (noFolderItem.shouldShowMasterPasswordReprompt && action.requiresPasswordReprompt ) { - onOverflowOptionClick(action) + overflowMasterPasswordRepromptAction = action } else { vaultHandlers.overflowOptionClick(action) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index e327f05801..84da90926f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -48,7 +48,6 @@ import com.x8bit.bitwarden.ui.platform.components.content.BitwardenErrorContent import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent 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.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.model.BitwardenPullToRefreshState import com.x8bit.bitwarden.ui.platform.components.model.rememberBitwardenPullToRefreshState @@ -69,7 +68,6 @@ import com.x8bit.bitwarden.ui.vault.components.VaultItemSelectionDialog import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemArgs -import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType @@ -243,21 +241,6 @@ private fun VaultScreenScaffold( ) } - var masterPasswordRepromptAction by remember { - mutableStateOf(null) - } - masterPasswordRepromptAction?.let { action -> - BitwardenMasterPasswordDialog( - onConfirmClick = { password -> - masterPasswordRepromptAction = null - vaultHandlers.masterPasswordRepromptSubmit(action, password) - }, - onDismissRequest = { - masterPasswordRepromptAction = null - }, - ) - } - BitwardenScaffold( topBar = { BitwardenMediumTopAppBar( @@ -373,7 +356,6 @@ private fun VaultScreenScaffold( is VaultState.ViewState.Content -> VaultContent( state = viewState, vaultHandlers = vaultHandlers, - onOverflowOptionClick = { masterPasswordRepromptAction = it }, modifier = Modifier.fillMaxSize(), ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 189bb9c8b2..fd15ae297e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -204,6 +204,9 @@ class VaultViewModel @Inject constructor( is VaultAction.DialogDismiss -> handleDialogDismiss() is VaultAction.RefreshPull -> handleRefreshPull() is VaultAction.OverflowOptionClick -> handleOverflowOptionClick(action) + is VaultAction.OverflowMasterPasswordRepromptSubmit -> { + handleOverflowMasterPasswordRepromptSubmit(action) + } is VaultAction.MasterPasswordRepromptSubmit -> { handleMasterPasswordRepromptSubmit(action) @@ -506,13 +509,13 @@ class VaultViewModel @Inject constructor( } } - private fun handleMasterPasswordRepromptSubmit( - action: VaultAction.MasterPasswordRepromptSubmit, + private fun handleOverflowMasterPasswordRepromptSubmit( + action: VaultAction.OverflowMasterPasswordRepromptSubmit, ) { viewModelScope.launch { val result = authRepository.validatePassword(action.password) sendAction( - VaultAction.Internal.ValidatePasswordResultReceive( + VaultAction.Internal.OverflowValidatePasswordResultReceive( overflowAction = action.overflowAction, result = result, ), @@ -520,6 +523,20 @@ class VaultViewModel @Inject constructor( } } + private fun handleMasterPasswordRepromptSubmit( + action: VaultAction.MasterPasswordRepromptSubmit, + ) { + viewModelScope.launch { + val result = authRepository.validatePassword(password = action.password) + sendAction( + VaultAction.Internal.ValidatePasswordResultReceive( + item = action.item, + result = result, + ), + ) + } + } + private fun handleCopyNoteClick(action: ListingItemOverflowAction.VaultAction.CopyNoteClick) { clipboardManager.setText( text = action.notes, @@ -616,6 +633,10 @@ class VaultViewModel @Inject constructor( action, ) + is VaultAction.Internal.OverflowValidatePasswordResultReceive -> { + handleOverflowValidatePasswordResultReceive(action) + } + is VaultAction.Internal.ValidatePasswordResultReceive -> { handleValidatePasswordResultReceive(action) } @@ -813,8 +834,8 @@ class VaultViewModel @Inject constructor( } } - private fun handleValidatePasswordResultReceive( - action: VaultAction.Internal.ValidatePasswordResultReceive, + private fun handleOverflowValidatePasswordResultReceive( + action: VaultAction.Internal.OverflowValidatePasswordResultReceive, ) { when (val result = action.result) { is ValidatePasswordResult.Error -> { @@ -847,6 +868,39 @@ class VaultViewModel @Inject constructor( } } + private fun handleValidatePasswordResultReceive( + action: VaultAction.Internal.ValidatePasswordResultReceive, + ) { + when (val result = action.result) { + is ValidatePasswordResult.Error -> { + mutableStateFlow.update { + it.copy( + dialog = VaultState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + error = result.error, + ), + ) + } + } + + is ValidatePasswordResult.Success -> { + if (result.isValid) { + trySendAction(VaultAction.VaultItemClick(vaultItem = action.item)) + } else { + mutableStateFlow.update { + it.copy( + dialog = VaultState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.invalid_master_password.asText(), + ), + ) + } + } + } + } + } + //endregion VaultAction Handlers } @@ -1462,11 +1516,20 @@ sealed class VaultAction { * User submitted their master password to authenticate before continuing with * the selected overflow action. */ - data class MasterPasswordRepromptSubmit( + data class OverflowMasterPasswordRepromptSubmit( val overflowAction: ListingItemOverflowAction.VaultAction, val password: String, ) : VaultAction() + /** + * User submitted their master password to authenticate before continuing with the primary + * action. + */ + data class MasterPasswordRepromptSubmit( + val item: VaultState.ViewState.VaultItem, + val password: String, + ) : VaultAction() + /** * The lifecycle of the VaultScreen has entered a resumed state. */ @@ -1524,11 +1587,19 @@ sealed class VaultAction { /** * Indicates that a result for verifying the user's master password has been received. */ - data class ValidatePasswordResultReceive( + data class OverflowValidatePasswordResultReceive( val overflowAction: ListingItemOverflowAction.VaultAction, val result: ValidatePasswordResult, ) : Internal() + /** + * Indicates that a result for verifying the user's master password has been received. + */ + data class ValidatePasswordResultReceive( + val item: VaultState.ViewState.VaultItem, + val result: ValidatePasswordResult, + ) : Internal() + /** * Indicates that a snackbar data was received. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt index 131fe9fb7e..282863c6e3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt @@ -36,7 +36,11 @@ data class VaultHandlers( val tryAgainClick: () -> Unit, val dialogDismiss: () -> Unit, val overflowOptionClick: (ListingItemOverflowAction.VaultAction) -> Unit, - val masterPasswordRepromptSubmit: (ListingItemOverflowAction.VaultAction, String) -> Unit, + val overflowMasterPasswordRepromptSubmit: ( + ListingItemOverflowAction.VaultAction, + String, + ) -> Unit, + val masterPasswordRepromptSubmit: (VaultState.ViewState.VaultItem, String) -> Unit, val dismissImportActionCard: () -> Unit, val importActionCardClick: () -> Unit, val flightRecorderGoToSettingsClick: () -> Unit, @@ -93,10 +97,18 @@ data class VaultHandlers( overflowOptionClick = { viewModel.trySendAction(VaultAction.OverflowOptionClick(it)) }, - masterPasswordRepromptSubmit = { action, password -> + overflowMasterPasswordRepromptSubmit = { action, password -> + viewModel.trySendAction( + VaultAction.OverflowMasterPasswordRepromptSubmit( + overflowAction = action, + password = password, + ), + ) + }, + masterPasswordRepromptSubmit = { item, password -> viewModel.trySendAction( VaultAction.MasterPasswordRepromptSubmit( - overflowAction = action, + item = item, password = password, ), ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt index efc2ea0dab..ef075fdaf9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -197,7 +197,7 @@ fun List?.toLoginIconData( /** * Transforms a [CipherView] into a [VaultState.ViewState.VaultItem]. */ -@Suppress("MagicNumber", "LongMethod") +@Suppress("MagicNumber", "LongMethod", "CyclomaticComplexMethod") private fun CipherView.toVaultItemOrNull( hasMasterPassword: Boolean, isIconLoadingDisabled: Boolean, @@ -220,7 +220,8 @@ private fun CipherView.toVaultItemOrNull( isPremiumUser = isPremiumUser, ), extraIconList = toLabelIcons(), - shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, + shouldShowMasterPasswordReprompt = hasMasterPassword && + reprompt == CipherRepromptType.PASSWORD, ) CipherType.SECURE_NOTE -> VaultState.ViewState.VaultItem.SecureNote( @@ -231,7 +232,8 @@ private fun CipherView.toVaultItemOrNull( isPremiumUser = isPremiumUser, ), extraIconList = toLabelIcons(), - shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, + shouldShowMasterPasswordReprompt = hasMasterPassword && + reprompt == CipherRepromptType.PASSWORD, ) CipherType.CARD -> VaultState.ViewState.VaultItem.Card( @@ -246,7 +248,8 @@ private fun CipherView.toVaultItemOrNull( isPremiumUser = isPremiumUser, ), extraIconList = toLabelIcons(), - shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, + shouldShowMasterPasswordReprompt = hasMasterPassword && + reprompt == CipherRepromptType.PASSWORD, ) CipherType.IDENTITY -> VaultState.ViewState.VaultItem.Identity( @@ -263,7 +266,8 @@ private fun CipherView.toVaultItemOrNull( isPremiumUser = isPremiumUser, ), extraIconList = toLabelIcons(), - shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, + shouldShowMasterPasswordReprompt = hasMasterPassword && + reprompt == CipherRepromptType.PASSWORD, ) CipherType.SSH_KEY -> VaultState.ViewState.VaultItem.SshKey( @@ -286,7 +290,8 @@ private fun CipherView.toVaultItemOrNull( isPremiumUser = isPremiumUser, ), extraIconList = toLabelIcons(), - shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, + shouldShowMasterPasswordReprompt = hasMasterPassword && + reprompt == CipherRepromptType.PASSWORD, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt index bb96d87da5..c52da2f42a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt @@ -13,7 +13,9 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource @@ -34,7 +36,9 @@ import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager import com.x8bit.bitwarden.data.platform.manager.util.RegisterScreenDataOnLifecycleEffect import com.x8bit.bitwarden.ui.platform.components.content.BitwardenErrorContent import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent +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.header.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.model.rememberBitwardenPullToRefreshState import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold @@ -91,7 +95,10 @@ fun VerificationCodeScreen( } } - VerificationCodeDialogs(dialogState = state.dialogState) + VerificationCodeDialogs( + dialogState = state.dialogState, + onDismissRequest = verificationCodeHandler.dismissDialog, + ) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( @@ -133,7 +140,9 @@ fun VerificationCodeScreen( VerificationCodeContent( items = viewState.verificationCodeDisplayItems.toImmutableList(), onCopyClick = verificationCodeHandler.copyClick, - itemClick = verificationCodeHandler.itemClick, + onItemClick = verificationCodeHandler.itemClick, + onMasterPasswordRepromptSubmit = verificationCodeHandler + .masterPasswordRepromptSubmit, modifier = Modifier.fillMaxSize(), ) } @@ -156,11 +165,23 @@ fun VerificationCodeScreen( @Composable private fun VerificationCodeDialogs( dialogState: VerificationCodeState.DialogState?, + onDismissRequest: () -> Unit, ) { when (dialogState) { - is VerificationCodeState.DialogState.Loading -> BitwardenLoadingDialog( - text = dialogState.message(), - ) + is VerificationCodeState.DialogState.Error -> { + BitwardenBasicDialog( + title = dialogState.title?.invoke(), + message = dialogState.message(), + throwable = dialogState.throwable, + onDismissRequest = onDismissRequest, + ) + } + + is VerificationCodeState.DialogState.Loading -> { + BitwardenLoadingDialog( + text = dialogState.message(), + ) + } null -> Unit } @@ -169,10 +190,21 @@ private fun VerificationCodeDialogs( @Composable private fun VerificationCodeContent( items: ImmutableList, - itemClick: (id: String) -> Unit, + onItemClick: (id: String) -> Unit, + onMasterPasswordRepromptSubmit: (id: String, password: String) -> Unit, onCopyClick: (text: String) -> Unit, modifier: Modifier = Modifier, ) { + var masterPasswordRepromptId by remember { mutableStateOf(value = null) } + masterPasswordRepromptId?.let { id -> + BitwardenMasterPasswordDialog( + onConfirmClick = { password -> + onMasterPasswordRepromptSubmit(id, password) + masterPasswordRepromptId = null + }, + onDismissRequest = { masterPasswordRepromptId = null }, + ) + } LazyColumn( modifier = modifier, ) { @@ -200,7 +232,11 @@ private fun VerificationCodeContent( hideAuthCode = it.hideAuthCode, onCopyClick = { onCopyClick(it.authCode) }, onItemClick = { - itemClick(it.id) + if (it.hideAuthCode) { + masterPasswordRepromptId = it.id + } else { + onItemClick(it.id) + } }, cardStyle = items.toListItemCardStyle(index = index, dividerPadding = 56.dp), modifier = Modifier diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModel.kt index 41f2af9174..30ec9e1057 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModel.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.vault.feature.verificationcode import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.data.repository.util.baseIconUrl @@ -11,6 +12,7 @@ import com.bitwarden.ui.util.concat 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.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -25,9 +27,12 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject +private const val KEY_STATE = "state" + /** * Handles [VerificationCodeAction], * and launches [VerificationCodeEvent] for the [VerificationCodeScreen]. @@ -35,13 +40,15 @@ import javax.inject.Inject @Suppress("TooManyFunctions") @HiltViewModel class VerificationCodeViewModel @Inject constructor( - authRepository: AuthRepository, + savedStateHandle: SavedStateHandle, + private val authRepository: AuthRepository, private val clipboardManager: BitwardenClipboardManager, private val environmentRepository: EnvironmentRepository, private val settingsRepository: SettingsRepository, private val vaultRepository: VaultRepository, ) : BaseViewModel( - initialState = run { + // We load the state from the savedStateHandle for testing purposes. + initialState = savedStateHandle[KEY_STATE] ?: run { VerificationCodeState( baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl, isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled, @@ -88,7 +95,9 @@ class VerificationCodeViewModel @Inject constructor( when (action) { is VerificationCodeAction.BackClick -> handleBackClick() is VerificationCodeAction.CopyClick -> handleCopyClick(action) + is VerificationCodeAction.DismissDialog -> handleDismissDialog() is VerificationCodeAction.ItemClick -> handleItemClick(action) + is VerificationCodeAction.MasterPasswordSubmit -> handleMasterPasswordSubmit(action) is VerificationCodeAction.LockClick -> handleLockClick() is VerificationCodeAction.RefreshClick -> handleRefreshClick() is VerificationCodeAction.RefreshPull -> handleRefreshPull() @@ -112,12 +121,28 @@ class VerificationCodeViewModel @Inject constructor( ) } + private fun handleDismissDialog() { + mutableStateFlow.update { it.copy(dialogState = null) } + } + private fun handleItemClick(action: VerificationCodeAction.ItemClick) { sendEvent( VerificationCodeEvent.NavigateToVaultItem(action.id), ) } + private fun handleMasterPasswordSubmit(action: VerificationCodeAction.MasterPasswordSubmit) { + viewModelScope.launch { + val result = authRepository.validatePassword(action.password) + sendAction( + VerificationCodeAction.Internal.ValidatePasswordResultReceive( + result = result, + cipherId = action.cipherId, + ), + ) + } + } + private fun handleLockClick() { vaultRepository.lockVaultForCurrentUser(isUserInitiated = true) } @@ -152,14 +177,54 @@ class VerificationCodeViewModel @Inject constructor( private fun handleInternalAction(action: VerificationCodeAction.Internal) { when (action) { - is VerificationCodeAction.Internal.IconLoadingSettingReceive -> + is VerificationCodeAction.Internal.IconLoadingSettingReceive -> { handleIconsSettingReceived(action) + } - is VerificationCodeAction.Internal.PullToRefreshEnableReceive -> + is VerificationCodeAction.Internal.PullToRefreshEnableReceive -> { handlePullToRefreshEnableReceive(action) + } - is VerificationCodeAction.Internal.AuthCodesReceive -> + is VerificationCodeAction.Internal.AuthCodesReceive -> { handleAuthCodeReceive(action) + } + + is VerificationCodeAction.Internal.ValidatePasswordResultReceive -> { + handleValidatePasswordResultReceive(action) + } + } + } + + private fun handleValidatePasswordResultReceive( + action: VerificationCodeAction.Internal.ValidatePasswordResultReceive, + ) { + when (val result = action.result) { + is ValidatePasswordResult.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = VerificationCodeState.DialogState.Error( + title = null, + message = R.string.generic_error_message.asText(), + throwable = result.error, + ), + ) + } + } + + is ValidatePasswordResult.Success -> { + if (result.isValid) { + trySendAction(VerificationCodeAction.ItemClick(id = action.cipherId)) + } else { + mutableStateFlow.update { + it.copy( + dialogState = VerificationCodeState.DialogState.Error( + title = null, + message = R.string.invalid_master_password.asText(), + ), + ) + } + } + } } } @@ -359,6 +424,15 @@ data class VerificationCodeState( * Represents the current state of any dialogs on the screen. */ sealed class DialogState : Parcelable { + /** + * Represents an error dialog with the given [title] and [message]. + */ + @Parcelize + data class Error( + val title: Text?, + val message: Text, + val throwable: Throwable? = null, + ) : DialogState() /** * Represents a loading dialog with the given [message]. @@ -457,6 +531,11 @@ sealed class VerificationCodeAction { */ data object BackClick : VerificationCodeAction() + /** + * User has dismissed the dialog. + */ + data object DismissDialog : VerificationCodeAction() + /** * User has clicked the copy button. */ @@ -469,6 +548,14 @@ sealed class VerificationCodeAction { */ data class ItemClick(val id: String) : VerificationCodeAction() + /** + * Validates the master password before viewing the cipher. + */ + data class MasterPasswordSubmit( + val cipherId: String, + val password: String, + ) : VerificationCodeAction() + /** * User has clicked the lock button. */ @@ -519,5 +606,13 @@ sealed class VerificationCodeAction { data class AuthCodesReceive( val verificationCodeData: DataState>, ) : Internal() + + /** + * The password validation data was received. + */ + data class ValidatePasswordResultReceive( + val result: ValidatePasswordResult, + val cipherId: String, + ) : Internal() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/handlers/VerificationCodeHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/handlers/VerificationCodeHandlers.kt index 1fe376b986..80b5777242 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/handlers/VerificationCodeHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/handlers/VerificationCodeHandlers.kt @@ -11,10 +11,12 @@ data class VerificationCodeHandlers( val backClick: () -> Unit, val searchIconClick: () -> Unit, val itemClick: (id: String) -> Unit, + val masterPasswordRepromptSubmit: (id: String, password: String) -> Unit, val refreshClick: () -> Unit, val syncClick: () -> Unit, val lockClick: () -> Unit, val copyClick: (text: String) -> Unit, + val dismissDialog: () -> Unit, ) { @Suppress("UndocumentedPublicClass") companion object { @@ -31,10 +33,16 @@ data class VerificationCodeHandlers( viewModel.trySendAction(VerificationCodeAction.SearchIconClick) }, itemClick = { viewModel.trySendAction(VerificationCodeAction.ItemClick(it)) }, + masterPasswordRepromptSubmit = { cipherId, password -> + viewModel.trySendAction( + action = VerificationCodeAction.MasterPasswordSubmit(cipherId, password), + ) + }, refreshClick = { viewModel.trySendAction(VerificationCodeAction.RefreshClick) }, syncClick = { viewModel.trySendAction(VerificationCodeAction.SyncClick) }, lockClick = { viewModel.trySendAction(VerificationCodeAction.LockClick) }, copyClick = { viewModel.trySendAction(VerificationCodeAction.CopyClick(it)) }, + dismissDialog = { viewModel.trySendAction(VerificationCodeAction.DismissDialog) }, ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt index e443ed55ee..12cfa89122 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt @@ -401,11 +401,11 @@ class SearchScreenTest : BitwardenComposeTest() { } @Test - fun `clicking on totp when reprompt is required should show master password dialog`() { + fun `clicking on item when reprompt is required should show master password dialog`() { mutableStateFlow.value = DEFAULT_STATE.copy( viewState = SearchState.ViewState.Content( displayItems = listOf( - createMockDisplayItemForCipher(number = 1, isTotp = true).copy( + createMockDisplayItemForCipher(number = 1).copy( shouldDisplayMasterPasswordReprompt = true, ), ), @@ -626,6 +626,7 @@ class SearchScreenTest : BitwardenComposeTest() { overflowAction = ListingItemOverflowAction.VaultAction.ViewClick( cipherId = "mockId-1", cipherType = CipherType.LOGIN, + requiresPasswordReprompt = true, ), ), ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt index 4d9d1e2a17..376c3fb661 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt @@ -650,8 +650,11 @@ class SearchViewModelTest : BaseViewModelTest() { viewModel.trySendAction( SearchAction.MasterPasswordRepromptSubmit( password = password, - masterPasswordRepromptData = MasterPasswordRepromptData.Totp( + masterPasswordRepromptData = MasterPasswordRepromptData.ViewItem( cipherId = cipherId, + itemType = SearchState.DisplayItem.ItemType.Vault( + type = CipherType.LOGIN, + ), ), ), ) @@ -1103,6 +1106,7 @@ class SearchViewModelTest : BaseViewModelTest() { ListingItemOverflowAction.VaultAction.ViewClick( cipherId = cipherId, cipherType = CipherType.LOGIN, + requiresPasswordReprompt = true, ), ), ) @@ -1140,7 +1144,6 @@ class SearchViewModelTest : BaseViewModelTest() { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isTotp = false, ) } returns expectedViewState val dataState = DataState.Loaded( @@ -1243,7 +1246,6 @@ class SearchViewModelTest : BaseViewModelTest() { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isTotp = false, ) } returns expectedViewState mutableVaultDataStateFlow.tryEmit( @@ -1356,7 +1358,6 @@ class SearchViewModelTest : BaseViewModelTest() { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isTotp = false, ) } returns expectedViewState val dataState = DataState.Error( @@ -1472,7 +1473,6 @@ class SearchViewModelTest : BaseViewModelTest() { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isTotp = false, ) } returns expectedViewState val dataState = DataState.NoNetwork( @@ -1637,7 +1637,6 @@ class SearchViewModelTest : BaseViewModelTest() { isAutofill = true, hasMasterPassword = true, isPremiumUser = true, - isTotp = false, ) } returns expectedViewState val dataState = DataState.Loaded( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt index eb13f8e34f..cd95880358 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt @@ -326,7 +326,6 @@ class SearchTypeDataExtensionsTest { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isTotp = true, ) assertEquals(SearchState.ViewState.Empty(message = null), result) @@ -352,7 +351,6 @@ class SearchTypeDataExtensionsTest { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isTotp = false, ) assertEquals( @@ -393,7 +391,6 @@ class SearchTypeDataExtensionsTest { isAutofill = true, hasMasterPassword = true, isPremiumUser = true, - isTotp = false, ) assertEquals( @@ -444,7 +441,6 @@ class SearchTypeDataExtensionsTest { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isTotp = true, ) assertEquals( @@ -474,7 +470,6 @@ class SearchTypeDataExtensionsTest { hasMasterPassword = true, isAutofill = false, isPremiumUser = true, - isTotp = false, ) assertEquals( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt index 1ca260d2fd..d3c6899bc4 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt @@ -16,7 +16,6 @@ import kotlinx.collections.immutable.persistentListOf fun createMockDisplayItemForCipher( number: Int, cipherType: CipherType = CipherType.LOGIN, - isTotp: Boolean = false, @DrawableRes fallbackIconRes: Int = R.drawable.ic_globe, ): SearchState.DisplayItem = when (cipherType) { @@ -47,6 +46,7 @@ fun createMockDisplayItemForCipher( ListingItemOverflowAction.VaultAction.ViewClick( cipherId = "mockId-$number", cipherType = CipherType.LOGIN, + requiresPasswordReprompt = true, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = "mockId-$number", @@ -72,7 +72,6 @@ fun createMockDisplayItemForCipher( totpCode = "mockTotp-$number", autofillSelectionOptions = emptyList(), shouldDisplayMasterPasswordReprompt = false, - isTotp = isTotp, itemType = SearchState.DisplayItem.ItemType.Vault(type = cipherType), ) } @@ -101,6 +100,7 @@ fun createMockDisplayItemForCipher( ListingItemOverflowAction.VaultAction.ViewClick( cipherId = "mockId-$number", cipherType = CipherType.SECURE_NOTE, + requiresPasswordReprompt = true, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = "mockId-$number", @@ -115,7 +115,6 @@ fun createMockDisplayItemForCipher( totpCode = null, autofillSelectionOptions = emptyList(), shouldDisplayMasterPasswordReprompt = false, - isTotp = false, itemType = SearchState.DisplayItem.ItemType.Vault(type = cipherType), ) } @@ -144,6 +143,7 @@ fun createMockDisplayItemForCipher( ListingItemOverflowAction.VaultAction.ViewClick( cipherId = "mockId-$number", cipherType = CipherType.CARD, + requiresPasswordReprompt = true, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = "mockId-$number", @@ -164,7 +164,6 @@ fun createMockDisplayItemForCipher( totpCode = null, autofillSelectionOptions = emptyList(), shouldDisplayMasterPasswordReprompt = false, - isTotp = false, itemType = SearchState.DisplayItem.ItemType.Vault(type = cipherType), ) } @@ -193,6 +192,7 @@ fun createMockDisplayItemForCipher( ListingItemOverflowAction.VaultAction.ViewClick( cipherId = "mockId-$number", cipherType = CipherType.IDENTITY, + requiresPasswordReprompt = true, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = "mockId-$number", @@ -204,7 +204,6 @@ fun createMockDisplayItemForCipher( totpCode = null, autofillSelectionOptions = emptyList(), shouldDisplayMasterPasswordReprompt = false, - isTotp = false, itemType = SearchState.DisplayItem.ItemType.Vault(type = cipherType), ) } @@ -228,6 +227,7 @@ fun createMockDisplayItemForCipher( ListingItemOverflowAction.VaultAction.ViewClick( cipherId = "mockId-$number", cipherType = CipherType.SSH_KEY, + requiresPasswordReprompt = true, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = "mockId-$number", @@ -239,7 +239,6 @@ fun createMockDisplayItemForCipher( totpCode = null, autofillSelectionOptions = emptyList(), shouldDisplayMasterPasswordReprompt = false, - isTotp = false, itemType = SearchState.DisplayItem.ItemType.Vault(type = cipherType), ) } @@ -296,7 +295,6 @@ fun createMockDisplayItemForSend( totpCode = null, autofillSelectionOptions = emptyList(), shouldDisplayMasterPasswordReprompt = false, - isTotp = false, itemType = SearchState.DisplayItem.ItemType.Sends(type = sendType), ) } @@ -343,7 +341,6 @@ fun createMockDisplayItemForSend( totpCode = null, autofillSelectionOptions = emptyList(), shouldDisplayMasterPasswordReprompt = false, - isTotp = false, itemType = SearchState.DisplayItem.ItemType.Sends(type = sendType), ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 25bca87f9e..6578d80b19 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onSiblings import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performSemanticsAction -import androidx.compose.ui.test.performTextInput import androidx.core.net.toUri import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.ui.util.asText @@ -222,53 +221,6 @@ class VaultItemScreenTest : BitwardenComposeTest() { .assert(hasAnyAncestor(isDialog())) } - @Test - fun `MasterPassword dialog should be displayed according to state`() { - composeTestRule.onNode(isDialog()).assertDoesNotExist() - composeTestRule.onNodeWithText("Master password confirmation").assertDoesNotExist() - - mutableStateFlow.update { - it.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.DeleteClick, - ), - ) - } - - composeTestRule - .onNodeWithText("Master password confirmation") - .assertIsDisplayed() - .assert(hasAnyAncestor(isDialog())) - } - - @Test - fun `Ok click on master password dialog should emit DismissDialogClick`() { - val enteredPassword = "pass1234" - val passwordRepromptAction = PasswordRepromptAction.EditClick - mutableStateFlow.update { - it.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = passwordRepromptAction, - ), - ) - } - - composeTestRule.onNodeWithText("Master password").performTextInput(enteredPassword) - composeTestRule - .onAllNodesWithText("Submit") - .filterToOne(hasAnyAncestor(isDialog())) - .performClick() - - verify { - viewModel.trySendAction( - VaultItemAction.Common.MasterPasswordSubmit( - masterPassword = enteredPassword, - action = passwordRepromptAction, - ), - ) - } - } - @Test fun `name should be displayed according to state`() { EMPTY_VIEW_STATES @@ -3193,7 +3145,6 @@ private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common = value = true, ), ), - requiresReprompt = true, requiresCloneConfirmation = false, attachments = listOf( VaultItemState.ViewState.Content.Common.AttachmentItem( @@ -3291,7 +3242,6 @@ private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common = lastUpdated = "12/31/69 06:16 PM", notes = null, customFields = emptyList(), - requiresReprompt = true, requiresCloneConfirmation = false, attachments = emptyList(), canDelete = true, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index ae34479787..1ff6a2e5ed 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -20,7 +20,6 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.UserState -import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager @@ -202,14 +201,12 @@ class VaultItemViewModelTest : BaseViewModelTest() { } @Test - fun `DeleteClick should show password dialog when re-prompt is required`() = + fun `DeleteClick should update state`() = runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) every { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -220,68 +217,9 @@ class VaultItemViewModelTest : BaseViewModelTest() { relatedLocations = persistentListOf(), ) } returns DEFAULT_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.Common.DeleteClick) - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.DeleteClick, - ), - ), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Test - fun `DeleteClick should update state when re-prompt is not required`() = - runTest { - val loginState = DEFAULT_VIEW_STATE.copy( - common = DEFAULT_COMMON - .copy(requiresReprompt = false), - ) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns loginState val expected = DEFAULT_STATE.copy( - viewState = DEFAULT_VIEW_STATE.copy( - common = DEFAULT_COMMON.copy( - requiresReprompt = false, - ), - ), + viewState = DEFAULT_VIEW_STATE, dialog = VaultItemState.DialogState.DeleteConfirmationPrompt( R.string.do_you_really_want_to_soft_delete_cipher.asText(), ), @@ -296,64 +234,56 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(expected, viewModel.stateFlow.value) } - @Suppress("MaxLineLength") @Test - fun `DeleteClick should update state when re-prompt is not required and it is a hard delete`() = - runTest { - val loginState = DEFAULT_VIEW_STATE.copy( - common = DEFAULT_COMMON - .copy( - requiresReprompt = false, - currentCipher = DEFAULT_COMMON - .currentCipher - ?.copy(deletedDate = Instant.MIN), - ), - ) - - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns loginState - - val expected = DEFAULT_STATE.copy( - viewState = loginState, - dialog = VaultItemState.DialogState.DeleteConfirmationPrompt( - R.string.do_you_really_want_to_permanently_delete_cipher.asText(), + fun `DeleteClick should update state when it is a hard delete`() = runTest { + val loginState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON + .copy( + currentCipher = DEFAULT_COMMON + .currentCipher + ?.copy(deletedDate = Instant.MIN), ), + ) + + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = null, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), ) + } returns loginState - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + val expected = DEFAULT_STATE.copy( + viewState = loginState, + dialog = VaultItemState.DialogState.DeleteConfirmationPrompt( + R.string.do_you_really_want_to_permanently_delete_cipher.asText(), + ), + ) - viewModel.trySendAction(VaultItemAction.Common.DeleteClick) - assertEquals(expected, viewModel.stateFlow.value) - } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + + viewModel.trySendAction(VaultItemAction.Common.DeleteClick) + assertEquals(expected, viewModel.stateFlow.value) + } @Test @Suppress("MaxLineLength") fun `ConfirmDeleteClick with DeleteCipherResult Success should should ShowToast and NavigateBack`() = runTest { - val loginViewState = DEFAULT_VIEW_STATE.copy( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - ) every { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), canDelete = true, canRestore = false, @@ -363,7 +293,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isIconLoadingDisabled = false, relatedLocations = persistentListOf(), ) - } returns loginViewState + } returns DEFAULT_VIEW_STATE mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) @@ -396,14 +326,10 @@ class VaultItemViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") fun `ConfirmDeleteClick with DeleteCipherResult Failure should should Show generic error`() = runTest { - val loginViewState = DEFAULT_VIEW_STATE.copy( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - ) every { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -413,7 +339,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isIconLoadingDisabled = false, relatedLocations = persistentListOf(), ) - } returns loginViewState + } returns DEFAULT_VIEW_STATE mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) @@ -432,7 +358,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals( DEFAULT_STATE.copy( - viewState = loginViewState, + viewState = DEFAULT_VIEW_STATE, dialog = VaultItemState.DialogState.Generic( message = R.string.generic_error_message.asText(), error = error, @@ -448,7 +374,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { val loginViewState = DEFAULT_VIEW_STATE.copy( common = DEFAULT_COMMON .copy( - requiresReprompt = false, currentCipher = DEFAULT_COMMON .currentCipher ?.copy(deletedDate = Instant.MIN), @@ -458,7 +383,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), canDelete = true, canRestore = false, @@ -497,53 +421,15 @@ class VaultItemViewModelTest : BaseViewModelTest() { coVerify { vaultRepo.hardDeleteCipher(cipherId = VAULT_ITEM_ID) } } - @Test - fun `on RestoreItemClick should prompt for master password when required`() = runTest { - every { - mockCipherView.toViewState( - previousState = any(), - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = createTotpCodeData(), - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns DEFAULT_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) - val viewModel = createViewModel(state = loginState) - assertEquals(loginState, viewModel.stateFlow.value) - - viewModel.trySendAction(VaultItemAction.Common.RestoreVaultItemClick) - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.RestoreItemClick, - ), - ), - viewModel.stateFlow.value, - ) - } - @Suppress("MaxLineLength") @Test fun `on RestoreItemClick when no need to prompt for master password updates pendingCipher state correctly`() = runTest { - val viewState = - DEFAULT_VIEW_STATE.copy(common = DEFAULT_COMMON.copy(requiresReprompt = false)) + val viewState = DEFAULT_VIEW_STATE every { mockCipherView.toViewState( previousState = any(), isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), canDelete = true, canRestore = false, @@ -560,7 +446,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) val loginState = DEFAULT_STATE.copy(viewState = viewState) - val viewModel = createViewModel(state = loginState) + val viewModel = createViewModel(state = DEFAULT_STATE) assertEquals(loginState, viewModel.stateFlow.value) // show dialog @@ -587,7 +473,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), canDelete = true, canRestore = false, @@ -634,7 +519,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), canDelete = true, canRestore = false, @@ -674,64 +558,12 @@ class VaultItemViewModelTest : BaseViewModelTest() { } @Test - fun `on EditClick should do nothing when ViewState is not Content`() = runTest { - val initialState = DEFAULT_STATE - val viewModel = createViewModel(state = initialState) - - assertEquals(initialState, viewModel.stateFlow.value) - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Common.EditClick) - expectNoEvents() - } - assertEquals(initialState, viewModel.stateFlow.value) - } - - @Test - fun `on EditClick should prompt for master password when required`() = runTest { + fun `on EditClick should navigate password`() = runTest { + val loginViewState = createViewState() every { mockCipherView.toViewState( previousState = any(), isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = createTotpCodeData(), - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns DEFAULT_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) - val viewModel = createViewModel(state = loginState) - assertEquals(loginState, viewModel.stateFlow.value) - - viewModel.trySendAction(VaultItemAction.Common.EditClick) - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.EditClick, - ), - ), - viewModel.stateFlow.value, - ) - } - - @Test - fun `on EditClick should navigate password is not required`() = runTest { - val loginViewState = createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - ) - every { - mockCipherView.toViewState( - previousState = any(), - isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -755,7 +587,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = loginViewState, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -777,200 +608,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } - @Suppress("MaxLineLength") - @Test - fun `on MasterPasswordSubmit should disabled required prompt when validatePassword success with valid password`() = - runTest { - val loginViewState = createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - ) - every { - mockCipherView.toViewState( - previousState = any(), - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns loginViewState - - val password = "password" - coEvery { - authRepo.validatePassword(password) - } returns ValidatePasswordResult.Success(isValid = true) - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - val loginState = DEFAULT_STATE.copy(viewState = loginViewState) - val viewModel = createViewModel(state = loginState) - - viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> - assertEquals(loginState, stateFlow.awaitItem()) - viewModel.trySendAction( - VaultItemAction.Common.MasterPasswordSubmit( - masterPassword = password, - action = PasswordRepromptAction.EditClick, - ), - ) - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.Loading( - message = R.string.loading.asText(), - ), - ), - stateFlow.awaitItem(), - ) - assertEquals( - loginState.copy( - viewState = loginViewState.copy( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - ), - ), - stateFlow.awaitItem(), - ) - assertEquals( - VaultItemEvent.NavigateToAddEdit( - itemId = DEFAULT_STATE.vaultItemId, - isClone = false, - type = VaultItemCipherType.LOGIN, - ), - eventFlow.awaitItem(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on MasterPasswordSubmit should show incorrect password dialog when validatePassword success with invalid password`() = - runTest { - val loginViewState = createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - ) - every { - mockCipherView.toViewState( - previousState = any(), - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns loginViewState - - val password = "password" - coEvery { - authRepo.validatePassword(password) - } returns ValidatePasswordResult.Success(isValid = false) - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - val loginState = DEFAULT_STATE.copy(viewState = loginViewState) - val viewModel = createViewModel(state = loginState) - - viewModel.stateFlow.test { - assertEquals(loginState, awaitItem()) - viewModel.trySendAction( - VaultItemAction.Common.MasterPasswordSubmit( - masterPassword = password, - action = PasswordRepromptAction.DeleteClick, - ), - ) - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.Loading( - message = R.string.loading.asText(), - ), - ), - awaitItem(), - ) - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.Generic( - message = R.string.invalid_master_password.asText(), - ), - ), - awaitItem(), - ) - } - } - - @Test - fun `on MasterPasswordSubmit should show error dialog when validatePassword Error`() = - runTest { - val loginViewState = createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - ) - every { - mockCipherView.toViewState( - previousState = any(), - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns loginViewState - val password = "password" - val error = Throwable("Fail!") - coEvery { - authRepo.validatePassword(password) - } returns ValidatePasswordResult.Error(error = error) - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - val loginState = DEFAULT_STATE.copy(viewState = loginViewState) - val viewModel = createViewModel(state = loginState) - - viewModel.stateFlow.test { - assertEquals(loginState, awaitItem()) - viewModel.trySendAction( - VaultItemAction.Common.MasterPasswordSubmit( - masterPassword = password, - action = PasswordRepromptAction.DeleteClick, - ), - ) - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.Loading( - message = R.string.loading.asText(), - ), - ), - awaitItem(), - ) - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.Generic( - message = R.string.generic_error_message.asText(), - error = error, - ), - ), - awaitItem(), - ) - } - } - @Test fun `on RefreshClick should sync`() = runTest { every { vaultRepo.sync(forced = true) } just runs @@ -983,68 +620,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } - @Suppress("MaxLineLength") @Test - fun `on CopyCustomHiddenFieldClick should show password dialog when re-prompt is required`() = - runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns DEFAULT_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.Common.CopyCustomHiddenFieldClick("field")) - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.CopyClick(value = "field"), - ), - ), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on CopyCustomHiddenFieldClick should call setText on ClipboardManager when re-prompt is not required`() { + fun `on CopyCustomHiddenFieldClick should call setText on ClipboardManager`() { val field = "field" every { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, canDelete = true, canRestore = false, canAssignToCollections = true, @@ -1054,7 +636,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isIconLoadingDisabled = false, relatedLocations = persistentListOf(), ) - } returns createViewState(common = DEFAULT_COMMON.copy(requiresReprompt = false)) + } returns createViewState() every { clipboardManager.setText(text = field) } just runs mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) @@ -1069,7 +651,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, canDelete = true, canRestore = false, canAssignToCollections = true, @@ -1099,269 +680,35 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } - @Suppress("MaxLineLength") @Test - fun `on HiddenFieldVisibilityClicked should show password dialog when re-prompt is required`() = - runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) - val field = VaultItemState.ViewState.Content.Common.Custom.HiddenField( + fun `on HiddenFieldVisibilityClicked should update hidden field visibility`() = runTest { + val hiddenField = + VaultItemState.ViewState.Content.Common.Custom.HiddenField( id = "12345", name = "hidden", value = "value", isCopyable = true, isVisible = false, ) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns DEFAULT_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction( - VaultItemAction.Common.HiddenFieldVisibilityClicked( - field = field, - isVisible = true, - ), - ) - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.ViewHiddenFieldClicked( - field = field, - isVisible = true, - ), - ), - ), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on HiddenFieldVisibilityClicked should update hidden field visibility when re-prompt is not required`() = - runTest { - val hiddenField = - VaultItemState.ViewState.Content.Common.Custom.HiddenField( - id = "12345", - name = "hidden", - value = "value", - isCopyable = true, - isVisible = false, - ) - val loginViewState = VaultItemState.ViewState.Content( - common = createCommonContent( - isEmpty = true, - isPremiumUser = true, - ).copy( - requiresReprompt = false, - customFields = listOf(hiddenField), - ), - type = createLoginContent(isEmpty = true), - ) - val loginState = DEFAULT_STATE.copy(viewState = loginViewState) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns loginViewState - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction( - VaultItemAction.Common.HiddenFieldVisibilityClicked( - field = hiddenField, - isVisible = true, - ), - ) - assertEquals( - loginState.copy( - viewState = loginViewState.copy( - common = createCommonContent(isEmpty = true, isPremiumUser = true).copy( - requiresReprompt = false, - customFields = listOf(hiddenField.copy(isVisible = true)), - ), - ), - ), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - organizationEventManager.trackEvent( - event = OrganizationEvent.CipherClientToggledHiddenFieldVisible( - cipherId = VAULT_ITEM_ID, - ), - ) - } - } - - @Test - fun `on AttachmentsClick should show password dialog when re-prompt is required`() = - runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns DEFAULT_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.Common.AttachmentsClick) - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.AttachmentsClick, - ), - ), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on AttachmentsClick should emit NavigateToAttachments when re-prompt is not required`() = - runTest { - val loginViewState = DEFAULT_VIEW_STATE.copy( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - ) - val loginState = DEFAULT_STATE.copy(viewState = loginViewState) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns loginViewState - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(loginState, viewModel.stateFlow.value) - - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Common.AttachmentsClick) - assertEquals( - VaultItemEvent.NavigateToAttachments(itemId = VAULT_ITEM_ID), - awaitItem(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on CloneClick should show confirmation when cipher contains a passkey`() = runTest { - val loginViewState = DEFAULT_VIEW_STATE.copy( - common = DEFAULT_COMMON.copy( - requiresReprompt = false, - requiresCloneConfirmation = true, + val loginViewState = VaultItemState.ViewState.Content( + common = createCommonContent( + isEmpty = true, + isPremiumUser = true, + ).copy( + customFields = listOf(hiddenField), ), + type = createLoginContent(isEmpty = true), ) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) every { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, + totpCodeItemData = null, canDelete = true, canRestore = false, canAssignToCollections = true, canEdit = true, - totpCodeItemData = null, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, isIconLoadingDisabled = false, relatedLocations = persistentListOf(), @@ -1373,13 +720,18 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) - - viewModel.trySendAction(VaultItemAction.Common.CloneClick) - + viewModel.trySendAction( + VaultItemAction.Common.HiddenFieldVisibilityClicked( + field = hiddenField, + isVisible = true, + ), + ) assertEquals( loginState.copy( - dialog = VaultItemState.DialogState.Fido2CredentialCannotBeCopiedConfirmationPrompt( - R.string.the_passkey_will_not_be_copied_to_the_cloned_item_do_you_want_to_continue_cloning_this_item.asText(), + viewState = loginViewState.copy( + common = createCommonContent(isEmpty = true, isPremiumUser = true).copy( + customFields = listOf(hiddenField.copy(isVisible = true)), + ), ), ), viewModel.stateFlow.value, @@ -1389,7 +741,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, canDelete = true, canRestore = false, canAssignToCollections = true, @@ -1399,81 +750,21 @@ class VaultItemViewModelTest : BaseViewModelTest() { isIconLoadingDisabled = false, relatedLocations = persistentListOf(), ) + organizationEventManager.trackEvent( + event = OrganizationEvent.CipherClientToggledHiddenFieldVisible( + cipherId = VAULT_ITEM_ID, + ), + ) } } - @Suppress("MaxLineLength") @Test - fun `on CloneClick should show confirmation before re-prompt when both are required`() { - val loginViewState = DEFAULT_VIEW_STATE.copy( - common = DEFAULT_COMMON.copy( - requiresReprompt = true, - requiresCloneConfirmation = true, - ), - ) - val loginState = DEFAULT_STATE.copy( - viewState = loginViewState, - ) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns loginViewState - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.Common.CloneClick) - - // Assert clone confirmation dialog is triggered - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.Fido2CredentialCannotBeCopiedConfirmationPrompt( - R.string.the_passkey_will_not_be_copied_to_the_cloned_item_do_you_want_to_continue_cloning_this_item.asText(), - ), - ), - viewModel.stateFlow.value, - ) - - // Simulate confirmation click. - viewModel.trySendAction(VaultItemAction.Common.ConfirmCloneWithoutFido2CredentialClick) - - // Assert MP dialog is triggered. - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.CloneClick, - ), - viewState = loginViewState.copy( - common = loginViewState.common.copy( - requiresCloneConfirmation = false, - ), - ), - ), - viewModel.stateFlow.value, - ) - } - - @Test - fun `on CloneClick should show password dialog when re-prompt is required`() = runTest { + fun `on AttachmentsClick should emit NavigateToAttachments`() = runTest { val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) every { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, canDelete = true, canRestore = false, canAssignToCollections = true, @@ -1490,11 +781,52 @@ class VaultItemViewModelTest : BaseViewModelTest() { mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) assertEquals(loginState, viewModel.stateFlow.value) + + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Common.AttachmentsClick) + assertEquals( + VaultItemEvent.NavigateToAttachments(itemId = VAULT_ITEM_ID), + awaitItem(), + ) + } + } + + @Test + fun `on CloneClick should show confirmation when cipher contains a passkey`() = runTest { + val loginViewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON.copy( + requiresCloneConfirmation = true, + ), + ) + val loginState = DEFAULT_STATE.copy(viewState = loginViewState) + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + totpCodeItemData = null, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } returns loginViewState + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction(VaultItemAction.Common.CloneClick) + + @Suppress("MaxLineLength") assertEquals( loginState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.CloneClick, + dialog = VaultItemState.DialogState.Fido2CredentialCannotBeCopiedConfirmationPrompt( + message = R.string.the_passkey_will_not_be_copied_to_the_cloned_item_do_you_want_to_continue_cloning_this_item.asText(), ), ), viewModel.stateFlow.value, @@ -1504,7 +836,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, canDelete = true, canRestore = false, canAssignToCollections = true, @@ -1517,139 +848,75 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } - @Suppress("MaxLineLength") @Test - fun `on CloneClick should emit NavigateToAddEdit when re-prompt is not required`() = - runTest { - val loginViewState = DEFAULT_VIEW_STATE.copy( - common = DEFAULT_COMMON.copy(requiresReprompt = false), + fun `on CloneClick should emit NavigateToAddEdit`() = runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + totpCodeItemData = null, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), ) - val loginState = DEFAULT_STATE.copy(viewState = loginViewState) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns loginViewState - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + } returns DEFAULT_VIEW_STATE + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - assertEquals(loginState, viewModel.stateFlow.value) + assertEquals(loginState, viewModel.stateFlow.value) - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Common.CloneClick) - assertEquals( - VaultItemEvent.NavigateToAddEdit( - itemId = VAULT_ITEM_ID, - isClone = true, - type = VaultItemCipherType.LOGIN, - ), - awaitItem(), - ) - } + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Common.CloneClick) + assertEquals( + VaultItemEvent.NavigateToAddEdit( + itemId = VAULT_ITEM_ID, + isClone = true, + type = VaultItemCipherType.LOGIN, + ), + awaitItem(), + ) } + } @Test - fun `on MoveToOrganizationClick should show password dialog when re-prompt is required`() = - runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns DEFAULT_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + fun `on MoveToOrganizationClick should emit NavigateToMoveToOrganization`() = runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + totpCodeItemData = null, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } returns DEFAULT_VIEW_STATE + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - assertEquals(loginState, viewModel.stateFlow.value) + assertEquals(loginState, viewModel.stateFlow.value) + + viewModel.eventFlow.test { viewModel.trySendAction(VaultItemAction.Common.MoveToOrganizationClick) assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.MoveToOrganizationClick, - ), - ), - viewModel.stateFlow.value, + VaultItemEvent.NavigateToMoveToOrganization(itemId = VAULT_ITEM_ID), + awaitItem(), ) - - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on MoveToOrganizationClick should emit NavigateToMoveToOrganization when re-prompt is not required`() = - runTest { - val loginViewState = DEFAULT_VIEW_STATE.copy( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - ) - val loginState = DEFAULT_STATE.copy(viewState = loginViewState) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns loginViewState - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(loginState, viewModel.stateFlow.value) - - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Common.MoveToOrganizationClick) - assertEquals( - VaultItemEvent.NavigateToMoveToOrganization(itemId = VAULT_ITEM_ID), - awaitItem(), - ) - } } + } @Test fun `on CollectionsClick should emit NavigateToCollections`() = runTest { @@ -1663,80 +930,15 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } - @Test - fun `on AttachmentDownloadClick should prompt for password if required`() = - runTest { - val loginViewState = createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = true), - ) - every { - mockCipherView.toViewState( - previousState = any(), - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = null, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns loginViewState - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - val loginState = DEFAULT_STATE.copy(viewState = loginViewState) - val viewModel = createViewModel(state = loginState) - - val attachment = VaultItemState.ViewState.Content.Common.AttachmentItem( - id = "attachment-id", - displaySize = "11 MB", - isLargeFile = true, - isDownloadAllowed = false, - url = "https://example.com", - title = "test.mp4", - ) - - viewModel.stateFlow.test { - assertEquals( - loginState, - awaitItem(), - ) - - viewModel.trySendAction( - VaultItemAction.Common.AttachmentDownloadClick(attachment), - ) - - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.AttachmentDownloadClick(attachment), - ), - ), - awaitItem(), - ) - } - - coVerify(exactly = 0) { - vaultRepo.downloadAttachment(any(), any()) - } - } - @Suppress("MaxLineLength") @Test fun `on AttachmentDownloadClick should show loading dialog, attempt to download an attachment, and display an error dialog on failure`() = runTest { - val loginViewState = createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - ) + val loginViewState = createViewState() every { mockCipherView.toViewState( previousState = any(), isPremiumUser = true, - hasMasterPassword = true, canDelete = true, canRestore = false, canAssignToCollections = true, @@ -1802,14 +1004,11 @@ class VaultItemViewModelTest : BaseViewModelTest() { @Test fun `on AttachmentDownloadClick should show loading dialog, attempt to download an attachment, and emit NavigateToSelectAttachmentSaveLocation on success`() = runTest { - val loginViewState = createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - ) + val loginViewState = createViewState() every { mockCipherView.toViewState( previousState = any(), isPremiumUser = true, - hasMasterPassword = true, canDelete = true, canRestore = false, canAssignToCollections = true, @@ -1982,7 +1181,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -2029,7 +1227,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, canDelete = true, canRestore = false, canAssignToCollections = true, @@ -2076,7 +1273,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, canDelete = true, canRestore = false, canAssignToCollections = true, @@ -2093,107 +1289,48 @@ class VaultItemViewModelTest : BaseViewModelTest() { } @Test - fun `on CopyPasswordClick should show password dialog when re-prompt is required`() = - runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = createTotpCodeData(), - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns DEFAULT_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = - DataState.Loaded(data = createVerificationCodeItem()) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyPasswordClick) - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.CopyClick( - value = DEFAULT_LOGIN_PASSWORD, - ), - ), - ), - viewModel.stateFlow.value, + fun `on CopyPasswordClick should call setText on the ClipboardManager`() = runTest { + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + totpCodeItemData = createTotpCodeData(), + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), ) + } returns createViewState() + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = + DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = createTotpCodeData(), - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } + viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyPasswordClick) - @Suppress("MaxLineLength") - @Test - fun `on CopyPasswordClick should call setText on the ClipboardManager when re-prompt is not required`() = - runTest { - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = createTotpCodeData(), - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns createViewState(common = DEFAULT_COMMON.copy(requiresReprompt = false)) - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = - DataState.Loaded(data = createVerificationCodeItem()) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyPasswordClick) - - verify(exactly = 1) { - clipboardManager.setText( - text = DEFAULT_LOGIN_PASSWORD, - toastDescriptorOverride = R.string.password.asText(), - ) - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - totpCodeItemData = createTotpCodeData(), - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } + verify(exactly = 1) { + clipboardManager.setText( + text = DEFAULT_LOGIN_PASSWORD, + toastDescriptorOverride = R.string.password.asText(), + ) + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + totpCodeItemData = createTotpCodeData(), + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) } + } @Test fun `on CopyTotpClick should call setText on the ClipboardManager`() = runTest { @@ -2239,7 +1376,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, canDelete = true, canRestore = false, canAssignToCollections = true, @@ -2249,7 +1385,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isIconLoadingDisabled = false, relatedLocations = persistentListOf(), ) - } returns createViewState(common = DEFAULT_COMMON.copy(requiresReprompt = false)) + } returns createViewState() mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) @@ -2266,7 +1402,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = createTotpCodeData(), canDelete = true, canRestore = false, @@ -2304,239 +1439,110 @@ class VaultItemViewModelTest : BaseViewModelTest() { } @Test - fun `on PasswordHistoryClick should show password dialog when re-prompt is required`() = - runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = createTotpCodeData(), - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns DEFAULT_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = - DataState.Loaded(data = createVerificationCodeItem()) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + fun `on PasswordHistoryClick should emit NavigateToPasswordHistory`() = runTest { + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = createTotpCodeData(), + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } returns createViewState() + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - assertEquals(loginState, viewModel.stateFlow.value) + viewModel.eventFlow.test { viewModel.trySendAction(VaultItemAction.Common.PasswordHistoryClick) assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.PasswordHistoryClick, - ), - ), - viewModel.stateFlow.value, + VaultItemEvent.NavigateToPasswordHistory(VAULT_ITEM_ID), + awaitItem(), ) - - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = createTotpCodeData(), - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } } - @Suppress("MaxLineLength") + verify(exactly = 1) { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = createTotpCodeData(), + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } + } + @Test - fun `on PasswordHistoryClick should emit NavigateToPasswordHistory when re-prompt is not required`() = - runTest { - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = createTotpCodeData(), - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - .returns( - createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), + fun `on PasswordVisibilityClicked should update password visibility`() = runTest { + val loginViewState = createViewState() + val loginState = DEFAULT_STATE.copy(viewState = loginViewState) + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = createTotpCodeData(), + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } returns loginViewState + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem()) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction( + VaultItemAction.ItemType.Login.PasswordVisibilityClicked( + true, + ), + ) + assertEquals( + loginState.copy( + viewState = loginViewState.copy( + type = DEFAULT_LOGIN_TYPE.copy( + passwordData = DEFAULT_LOGIN_TYPE.passwordData!!.copy(isVisible = true), ), - ) - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = - DataState.Loaded(data = createVerificationCodeItem()) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + ), + ), + viewModel.stateFlow.value, + ) - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Common.PasswordHistoryClick) - assertEquals( - VaultItemEvent.NavigateToPasswordHistory(VAULT_ITEM_ID), - awaitItem(), - ) - } - - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = createTotpCodeData(), - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on PasswordVisibilityClicked should show password dialog when re-prompt is required`() = - runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = createTotpCodeData(), - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns DEFAULT_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = - DataState.Loaded(data = createVerificationCodeItem()) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction( - VaultItemAction.ItemType.Login.PasswordVisibilityClicked( - isVisible = true, - ), - ) - assertEquals( - loginState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.ViewPasswordClick(isVisible = true), - ), - ), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = createTotpCodeData(), - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on PasswordVisibilityClicked should update password visibility when re-prompt is not required`() = - runTest { - val loginViewState = createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - ) - val loginState = DEFAULT_STATE.copy(viewState = loginViewState) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = createTotpCodeData(), - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns loginViewState - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = - DataState.Loaded(data = createVerificationCodeItem()) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction( - VaultItemAction.ItemType.Login.PasswordVisibilityClicked( - true, - ), + verify(exactly = 1) { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = createTotpCodeData(), + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), ) - assertEquals( - loginState.copy( - viewState = loginViewState.copy( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - type = DEFAULT_LOGIN_TYPE.copy( - passwordData = DEFAULT_LOGIN_TYPE.passwordData!!.copy(isVisible = true), - ), - ), + organizationEventManager.trackEvent( + event = OrganizationEvent.CipherClientToggledPasswordVisible( + cipherId = VAULT_ITEM_ID, ), - viewModel.stateFlow.value, ) - - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = createTotpCodeData(), - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - organizationEventManager.trackEvent( - event = OrganizationEvent.CipherClientToggledPasswordVisible( - cipherId = VAULT_ITEM_ID, - ), - ) - } } + } } @Nested @@ -2553,68 +1559,56 @@ class VaultItemViewModelTest : BaseViewModelTest() { } @Test - fun `on CopyNumberClick should show password dialog when re-prompt is required`() = - runTest { - val cardState = DEFAULT_STATE.copy(viewState = CARD_VIEW_STATE) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns CARD_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(cardState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick) - assertEquals( - cardState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.CopyClick( - value = requireNotNull(DEFAULT_CARD_TYPE.number).number, - ), - ), - ), - viewModel.stateFlow.value, + fun `on CopyNumberClick should call setText on the ClipboardManager`() = runTest { + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = null, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), ) + } returns createViewState(type = DEFAULT_CARD_TYPE) + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } + viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick) + + verify(exactly = 1) { + clipboardManager.setText( + text = "12345436", + toastDescriptorOverride = R.string.number.asText(), + ) + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = null, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) } + } @Suppress("MaxLineLength") @Test - fun `on CopyNumberClick should call setText on the ClipboardManager when re-prompt is not required`() = + fun `on NumberVisibilityClick should call trackEvent on the OrganizationEventManager and update the ViewState`() = runTest { every { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -2624,114 +1618,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isIconLoadingDisabled = false, relatedLocations = persistentListOf(), ) - } returns createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - type = DEFAULT_CARD_TYPE, - ) - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick) - - verify(exactly = 1) { - clipboardManager.setText( - text = "12345436", - toastDescriptorOverride = R.string.number.asText(), - ) - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Test - fun `on NumberVisibilityClick should show password dialog when re-prompt is required`() = - runTest { - val cardState = DEFAULT_STATE.copy(viewState = CARD_VIEW_STATE) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns CARD_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(cardState, viewModel.stateFlow.value) - viewModel.trySendAction( - VaultItemAction.ItemType.Card.NumberVisibilityClick(isVisible = true), - ) - assertEquals( - cardState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.ViewNumberClick(isVisible = true), - ), - ), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on NumberVisibilityClick should call trackEvent on the OrganizationEventManager and update the ViewState when re-prompt is not required`() = - runTest { - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - type = DEFAULT_CARD_TYPE, - ) + } returns createViewState(type = DEFAULT_CARD_TYPE) mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) @@ -2750,7 +1637,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -2764,171 +1650,11 @@ class VaultItemViewModelTest : BaseViewModelTest() { } @Test - fun `on CopySecurityCodeClick should show password dialog when re-prompt is required`() = - runTest { - val cardState = DEFAULT_STATE.copy(viewState = CARD_VIEW_STATE) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns CARD_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(cardState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick) - assertEquals( - cardState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.CopyClick( - value = requireNotNull(DEFAULT_CARD_TYPE.securityCode).code, - ), - ), - ), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on CopySecurityCodeClick should call setText on the ClipboardManager when re-prompt is not required`() = - runTest { - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - type = DEFAULT_CARD_TYPE, - ) - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick) - - verify(exactly = 1) { - clipboardManager.setText( - text = "987", - toastDescriptorOverride = R.string.security_code.asText(), - ) - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Test - fun `on CodeVisibilityClick should show password dialog when re-prompt is required`() = - runTest { - val cardState = DEFAULT_STATE.copy(viewState = CARD_VIEW_STATE) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns CARD_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(cardState, viewModel.stateFlow.value) - viewModel.trySendAction( - VaultItemAction.ItemType.Card.CodeVisibilityClick(isVisible = true), - ) - assertEquals( - cardState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.ViewCodeClick(isVisible = true), - ), - ), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on CodeVisibilityClick should call trackEvent on the OrganizationEventManager and update the ViewState when re-prompt is not required`() { + fun `on CopySecurityCodeClick should call setText on the ClipboardManager`() = runTest { every { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -2938,10 +1664,51 @@ class VaultItemViewModelTest : BaseViewModelTest() { isIconLoadingDisabled = false, relatedLocations = persistentListOf(), ) - } returns createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - type = DEFAULT_CARD_TYPE, - ) + } returns createViewState(type = DEFAULT_CARD_TYPE) + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + + viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick) + + verify(exactly = 1) { + clipboardManager.setText( + text = "987", + toastDescriptorOverride = R.string.security_code.asText(), + ) + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = null, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on CodeVisibilityClick should call trackEvent on the OrganizationEventManager and update the ViewState`() { + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = null, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } returns createViewState(type = DEFAULT_CARD_TYPE) mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) @@ -2960,7 +1727,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -2993,7 +1759,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -3019,203 +1784,89 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } - @Suppress("MaxLineLength") @Test - fun `on PrivateKeyVisibilityClick should show private key when re-prompt is not required`() = - runTest { - val sshKeyViewState = createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - type = DEFAULT_SSH_KEY_TYPE, + fun `on PrivateKeyVisibilityClick should show private key`() = runTest { + val sshKeyViewState = createViewState(type = DEFAULT_SSH_KEY_TYPE) + val sshKeyState = DEFAULT_STATE.copy(viewState = sshKeyViewState) + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = null, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), ) - val sshKeyState = DEFAULT_STATE.copy(viewState = sshKeyViewState) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns sshKeyViewState - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + } returns sshKeyViewState + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - assertEquals(sshKeyState, viewModel.stateFlow.value) - viewModel.trySendAction( - VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked( - isVisible = true, + assertEquals(sshKeyState, viewModel.stateFlow.value) + viewModel.trySendAction( + VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked( + isVisible = true, + ), + ) + assertEquals( + sshKeyState.copy( + viewState = sshKeyViewState.copy( + type = DEFAULT_SSH_KEY_TYPE.copy(showPrivateKey = true), ), + ), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = null, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), ) - assertEquals( - sshKeyState.copy( - viewState = sshKeyViewState.copy( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - type = DEFAULT_SSH_KEY_TYPE.copy(showPrivateKey = true), - ), - ), - viewModel.stateFlow.value, - ) - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on PrivateKeyVisibilityClick should show password dialog when re-prompt is required`() = - runTest { - val sshKeyState = DEFAULT_STATE.copy(viewState = SSH_KEY_VIEW_STATE) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns SSH_KEY_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - assertEquals(sshKeyState, viewModel.stateFlow.value) - viewModel.trySendAction( - VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked( - isVisible = true, - ), - ) - assertEquals( - sshKeyState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - PasswordRepromptAction.ViewPrivateKeyClicked(isVisible = true), - ), - ), - viewModel.stateFlow.value, - ) - verify(exactly = 1) { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `onPrivateKeyCopyClick should copy private key to clipboard when re-prompt is not required`() = - runTest { - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns createViewState( - common = DEFAULT_COMMON.copy(requiresReprompt = false), - type = DEFAULT_SSH_KEY_TYPE, - ) - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - - viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick) - - verify(exactly = 1) { - clipboardManager.setText( - text = DEFAULT_SSH_KEY_TYPE.privateKey, - toastDescriptorOverride = R.string.private_key.asText(), - ) - } } + } @Test - fun `onPrivateKeyCopyClick should show password dialog when re-prompt is required`() = - runTest { - val sshKeyState = DEFAULT_STATE.copy(viewState = SSH_KEY_VIEW_STATE) - every { - mockCipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = null, - canDelete = true, - canRestore = false, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, - isIconLoadingDisabled = false, - relatedLocations = persistentListOf(), - ) - } returns SSH_KEY_VIEW_STATE - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) - mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) - mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) + fun `onPrivateKeyCopyClick should copy private key to clipboard`() = runTest { + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = null, + canDelete = true, + canRestore = false, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + relatedLocations = persistentListOf(), + ) + } returns createViewState(type = DEFAULT_SSH_KEY_TYPE) + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + mutableFoldersStateFlow.value = DataState.Loaded(emptyList()) - viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick) + viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick) - assertEquals( - sshKeyState.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.CopyClick( - value = DEFAULT_SSH_KEY_TYPE.privateKey, - ), - ), - ), - viewModel.stateFlow.value, + verify(exactly = 1) { + clipboardManager.setText( + text = DEFAULT_SSH_KEY_TYPE.privateKey, + toastDescriptorOverride = R.string.private_key.asText(), ) - verify(exactly = 0) { - clipboardManager.setText( - text = DEFAULT_SSH_KEY_TYPE.privateKey, - toastDescriptorOverride = R.string.private_key.asText(), - ) - } } + } @Test fun `on CopyFingerprintClick should copy fingerprint to clipboard`() = runTest { @@ -3223,7 +1874,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -3265,7 +1915,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -3421,7 +2070,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -3487,7 +2135,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = false, canRestore = false, @@ -3552,7 +2199,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = true, @@ -3626,7 +2272,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -3672,7 +2317,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -3713,7 +2357,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { mockCipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, canDelete = true, canRestore = false, @@ -3958,7 +2601,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD, ), ), - requiresReprompt = true, requiresCloneConfirmation = false, currentCipher = createMockCipherView(number = 1), attachments = listOf( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt index 491c61c5cb..1b9b5d3403 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt @@ -38,105 +38,12 @@ class CipherViewExtensionsTest { unmockkStatic(Uri::class) } - @Suppress("MaxLineLength") - @Test - fun `toViewState should transform full CipherView into ViewState Login Content maintaining re-prompt and visibility state`() { - val cipherView = createCipherView(type = CipherType.LOGIN, isEmpty = false) - val viewState = cipherView.toViewState( - previousState = VaultItemState.ViewState.Content( - common = createCommonContent(isEmpty = false, isPremiumUser = true).copy( - currentCipher = cipherView, - // This re-prompt state should be preserved - requiresReprompt = false, - ), - type = createLoginContent(isEmpty = false).copy( - passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( - password = "password", - // This visibility state should be preserved - isVisible = true, - canViewPassword = false, - ), - ), - ), - isPremiumUser = true, - hasMasterPassword = true, - totpCodeItemData = TotpCodeItemData( - periodSeconds = 30, - timeLeftSeconds = 15, - verificationCode = "123456", - totpCode = "testCode", - ), - clock = fixedClock, - canDelete = true, - canRestore = true, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = "https://example.com/", - isIconLoadingDisabled = true, - relatedLocations = persistentListOf(), - ) - - assertEquals( - VaultItemState.ViewState.Content( - common = createCommonContent(isEmpty = false, isPremiumUser = true).copy( - currentCipher = cipherView, - requiresReprompt = false, - ), - type = createLoginContent(isEmpty = false).copy( - passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( - password = "password", - isVisible = true, - canViewPassword = false, - ), - ), - ), - viewState, - ) - } - - @Suppress("MaxLineLength") - @Test - fun `toViewState should transform full CipherView into ViewState Login Content without master password reprompt`() { - val cipherView = createCipherView(type = CipherType.LOGIN, isEmpty = false) - val viewState = cipherView.toViewState( - previousState = null, - isPremiumUser = true, - hasMasterPassword = false, - totpCodeItemData = TotpCodeItemData( - periodSeconds = 30, - timeLeftSeconds = 15, - verificationCode = "123456", - totpCode = "testCode", - ), - clock = fixedClock, - canDelete = true, - canRestore = true, - canAssignToCollections = true, - canEdit = true, - baseIconUrl = "https://example.com/", - isIconLoadingDisabled = true, - relatedLocations = persistentListOf(), - ) - - assertEquals( - VaultItemState.ViewState.Content( - common = createCommonContent(isEmpty = false, isPremiumUser = true).copy( - currentCipher = cipherView, - requiresReprompt = false, - ), - type = createLoginContent(isEmpty = false), - ), - viewState, - ) - } - @Test fun `toViewState should transform full CipherView into ViewState Login Content with premium`() { val cipherView = createCipherView(type = CipherType.LOGIN, isEmpty = false) val viewState = cipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = TotpCodeItemData( periodSeconds = 30, timeLeftSeconds = 15, @@ -171,7 +78,6 @@ class CipherViewExtensionsTest { val viewState = cipherView.toViewState( previousState = null, isPremiumUser = isPremiumUser, - hasMasterPassword = true, totpCodeItemData = TotpCodeItemData( periodSeconds = 30, timeLeftSeconds = 15, @@ -212,7 +118,6 @@ class CipherViewExtensionsTest { val viewState = cipherView.toViewState( previousState = null, isPremiumUser = isPremiumUser, - hasMasterPassword = true, totpCodeItemData = TotpCodeItemData( periodSeconds = 30, timeLeftSeconds = 15, @@ -248,7 +153,6 @@ class CipherViewExtensionsTest { val viewState = cipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, canDelete = true, @@ -277,7 +181,6 @@ class CipherViewExtensionsTest { val viewState = cipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, canDelete = true, @@ -309,7 +212,6 @@ class CipherViewExtensionsTest { val viewState = cipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, canDelete = true, @@ -351,7 +253,6 @@ class CipherViewExtensionsTest { val viewState = cipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, canDelete = true, @@ -398,7 +299,6 @@ class CipherViewExtensionsTest { val result = cipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, canDelete = true, @@ -447,7 +347,6 @@ class CipherViewExtensionsTest { val viewState = cipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, canDelete = true, @@ -480,7 +379,6 @@ class CipherViewExtensionsTest { val viewState = cipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, canDelete = true, @@ -511,7 +409,6 @@ class CipherViewExtensionsTest { val viewState = cipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, canDelete = true, @@ -559,7 +456,6 @@ class CipherViewExtensionsTest { val viewState = cipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, canDelete = true, @@ -587,7 +483,6 @@ class CipherViewExtensionsTest { val viewState = cipherView.toViewState( previousState = null, isPremiumUser = true, - hasMasterPassword = true, totpCodeItemData = null, clock = fixedClock, canDelete = true, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt index d0dc9b3b93..b8b7ad19be 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt @@ -172,7 +172,6 @@ fun createCommonContent( lastUpdated = "1/1/70 12:16 AM", notes = null, customFields = emptyList(), - requiresReprompt = true, requiresCloneConfirmation = false, attachments = emptyList(), canDelete = true, @@ -226,7 +225,6 @@ fun createCommonContent( ) .toCustomField(null), ), - requiresReprompt = true, requiresCloneConfirmation = true, attachments = listOf( VaultItemState.ViewState.Content.Common.AttachmentItem( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index 65e9105e43..8600c5a3c8 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -1121,7 +1121,6 @@ class VaultItemListingScreenTest : BitwardenComposeTest() { displayCollectionList = emptyList(), displayItemList = listOf( createDisplayItem(number = 1).copy( - isTotp = true, shouldShowMasterPasswordReprompt = true, ), ), @@ -2373,7 +2372,6 @@ private fun createDisplayItem(number: Int): VaultItemListingState.DisplayItem = isCredentialCreation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = null, - isTotp = false, itemType = VaultItemListingState.DisplayItem.ItemType.Sends(type = SendType.TEXT), ) @@ -2400,6 +2398,5 @@ private fun createCipherDisplayItem(number: Int): VaultItemListingState.DisplayI isCredentialCreation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = null, - isTotp = true, itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = CipherType.LOGIN), ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index fd2d031796..c764353028 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -1178,6 +1178,17 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { runTest { val cipherId = "cipherId-1234" val password = "password" + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.AddTotpLoginItem( + data = TotpData( + uri = "uri", + issuer = "issuer", + accountName = "Name-1", + secret = "secret", + digits = 6, + period = 30, + algorithm = TotpData.CryptoHashAlgorithm.SHA_1, + ), + ) val viewModel = createVaultItemListingViewModel() coEvery { authRepository.validatePassword(password = password) @@ -1187,8 +1198,11 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { viewModel.trySendAction( VaultItemListingsAction.MasterPasswordRepromptSubmit( password = password, - masterPasswordRepromptData = MasterPasswordRepromptData.Totp( - cipherId = cipherId, + masterPasswordRepromptData = MasterPasswordRepromptData.ViewItem( + id = cipherId, + itemType = VaultItemListingState.DisplayItem.ItemType.Vault( + type = CipherType.LOGIN, + ), ), ), ) @@ -1738,6 +1752,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ListingItemOverflowAction.VaultAction.ViewClick( cipherId = cipherId, cipherType = CipherType.LOGIN, + requiresPasswordReprompt = true, ), ), ) @@ -1903,7 +1918,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createMockDisplayItemForCipher( number = 1, secondSubtitleTestTag = "PasskeySite", - isTotp = true, ), ), displayFolderList = emptyList(), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt index 7407be735c..4f41bc0540 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt @@ -12,14 +12,12 @@ import kotlinx.collections.immutable.persistentListOf /** * Create a mock [VaultItemListingState.DisplayItem] with a given [number]. */ -@Suppress("LongParameterList") fun createMockDisplayItemForCipher( number: Int, cipherType: CipherType = CipherType.LOGIN, subtitle: String? = "mockUsername-$number", secondSubtitleTestTag: String? = null, requiresPasswordReprompt: Boolean = true, - isTotp: Boolean = false, ): VaultItemListingState.DisplayItem = when (cipherType) { CipherType.LOGIN -> { @@ -51,6 +49,7 @@ fun createMockDisplayItemForCipher( ListingItemOverflowAction.VaultAction.ViewClick( cipherId = "mockId-$number", cipherType = cipherType, + requiresPasswordReprompt = requiresPasswordReprompt, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = "mockId-$number", @@ -77,7 +76,6 @@ fun createMockDisplayItemForCipher( isCredentialCreation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = "LoginCipherIcon", - isTotp = isTotp, itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = cipherType), ) } @@ -108,6 +106,7 @@ fun createMockDisplayItemForCipher( ListingItemOverflowAction.VaultAction.ViewClick( cipherId = "mockId-$number", cipherType = cipherType, + requiresPasswordReprompt = requiresPasswordReprompt, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = "mockId-$number", @@ -123,7 +122,6 @@ fun createMockDisplayItemForCipher( isCredentialCreation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = "SecureNoteCipherIcon", - isTotp = false, itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = cipherType), ) } @@ -154,6 +152,7 @@ fun createMockDisplayItemForCipher( ListingItemOverflowAction.VaultAction.ViewClick( cipherId = "mockId-$number", cipherType = cipherType, + requiresPasswordReprompt = requiresPasswordReprompt, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = "mockId-$number", @@ -175,7 +174,6 @@ fun createMockDisplayItemForCipher( isCredentialCreation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = "CardCipherIcon", - isTotp = false, itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = cipherType), ) } @@ -206,6 +204,7 @@ fun createMockDisplayItemForCipher( ListingItemOverflowAction.VaultAction.ViewClick( cipherId = "mockId-$number", cipherType = cipherType, + requiresPasswordReprompt = requiresPasswordReprompt, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = "mockId-$number", @@ -218,7 +217,6 @@ fun createMockDisplayItemForCipher( isCredentialCreation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = "IdentityCipherIcon", - isTotp = false, itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = cipherType), ) } @@ -249,6 +247,7 @@ fun createMockDisplayItemForCipher( ListingItemOverflowAction.VaultAction.ViewClick( cipherId = "mockId-$number", cipherType = cipherType, + requiresPasswordReprompt = requiresPasswordReprompt, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = "mockId-$number", @@ -261,7 +260,6 @@ fun createMockDisplayItemForCipher( isCredentialCreation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = "SshKeyCipherIcon", - isTotp = false, itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = cipherType), ) } @@ -321,7 +319,6 @@ fun createMockDisplayItemForSend( isCredentialCreation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = null, - isTotp = false, itemType = VaultItemListingState.DisplayItem.ItemType.Sends(type = sendType), ) } @@ -371,7 +368,6 @@ fun createMockDisplayItemForSend( isCredentialCreation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = null, - isTotp = false, itemType = VaultItemListingState.DisplayItem.ItemType.Sends(type = sendType), ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherViewExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherViewExtensionsTest.kt index f656fde148..f80da99fac 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherViewExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherViewExtensionsTest.kt @@ -33,6 +33,7 @@ class CipherViewExtensionsTest { ListingItemOverflowAction.VaultAction.ViewClick( cipherId = id, cipherType = CipherType.LOGIN, + requiresPasswordReprompt = false, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = id, @@ -70,6 +71,7 @@ class CipherViewExtensionsTest { ListingItemOverflowAction.VaultAction.ViewClick( cipherId = id, cipherType = CipherType.LOGIN, + requiresPasswordReprompt = false, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = id, @@ -107,6 +109,7 @@ class CipherViewExtensionsTest { ListingItemOverflowAction.VaultAction.ViewClick( cipherId = id, cipherType = CipherType.LOGIN, + requiresPasswordReprompt = true, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = id, @@ -144,6 +147,7 @@ class CipherViewExtensionsTest { ListingItemOverflowAction.VaultAction.ViewClick( cipherId = id, cipherType = CipherType.LOGIN, + requiresPasswordReprompt = true, ), ), result, @@ -169,6 +173,7 @@ class CipherViewExtensionsTest { ListingItemOverflowAction.VaultAction.ViewClick( cipherId = id, cipherType = CipherType.CARD, + requiresPasswordReprompt = true, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = id, @@ -211,6 +216,7 @@ class CipherViewExtensionsTest { ListingItemOverflowAction.VaultAction.ViewClick( cipherId = id, cipherType = CipherType.CARD, + requiresPasswordReprompt = false, ), ), result, @@ -231,6 +237,7 @@ class CipherViewExtensionsTest { ListingItemOverflowAction.VaultAction.ViewClick( cipherId = id, cipherType = CipherType.IDENTITY, + requiresPasswordReprompt = false, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = id, @@ -254,13 +261,14 @@ class CipherViewExtensionsTest { identity = createMockIdentityView(number = 1), ) - val result = cipher.toOverflowActions(hasMasterPassword = true, false) + val result = cipher.toOverflowActions(hasMasterPassword = true, isPremiumUser = false) assertEquals( listOf( ListingItemOverflowAction.VaultAction.ViewClick( cipherId = id, cipherType = CipherType.IDENTITY, + requiresPasswordReprompt = true, ), ), result, @@ -283,6 +291,7 @@ class CipherViewExtensionsTest { ListingItemOverflowAction.VaultAction.ViewClick( cipherId = id, cipherType = CipherType.SECURE_NOTE, + requiresPasswordReprompt = true, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = id, @@ -315,6 +324,7 @@ class CipherViewExtensionsTest { ListingItemOverflowAction.VaultAction.ViewClick( cipherId = id, cipherType = CipherType.SECURE_NOTE, + requiresPasswordReprompt = false, ), ), result, @@ -346,6 +356,7 @@ class CipherViewExtensionsTest { ListingItemOverflowAction.VaultAction.ViewClick( cipherId = id, cipherType = CipherType.LOGIN, + requiresPasswordReprompt = true, ), ), result, @@ -377,6 +388,7 @@ class CipherViewExtensionsTest { ListingItemOverflowAction.VaultAction.ViewClick( cipherId = id, cipherType = CipherType.LOGIN, + requiresPasswordReprompt = true, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = id, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index a3abb4bb59..b774765d0e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -7,17 +7,20 @@ import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasScrollToNodeAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isPopup import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput import androidx.core.net.toUri import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.repository.model.Environment @@ -26,6 +29,7 @@ import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.assertNoDialogExists import com.bitwarden.ui.util.assertScrollableNodeDoesNotExist import com.bitwarden.ui.util.onNodeWithTextAfterScroll +import com.bitwarden.vault.CipherType import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest @@ -51,6 +55,7 @@ import com.x8bit.bitwarden.ui.util.performYesDialogButtonClick import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemArgs +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType @@ -885,6 +890,263 @@ class VaultScreenTest : BitwardenComposeTest() { } } + @Suppress("MaxLineLength") + @Test + fun `clicking a favorite item with password prompt should prompt for password before dismissing upon Cancel`() { + val itemText = "Test Item" + val userName = "Bitwarden" + val vaultItem = VaultState.ViewState.VaultItem.Login( + id = "12345", + name = itemText.asText(), + username = userName.asText(), + overflowOptions = emptyList(), + shouldShowMasterPasswordReprompt = true, + ) + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + favoriteItems = listOf(vaultItem), + ), + ) + } + + composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText)) + composeTestRule + .onNodeWithText(text = itemText) + .assertTextEquals(itemText, userName) + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText( + text = "This action is protected, to continue please re-enter your master " + + "password to verify your identity.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify(exactly = 0) { + viewModel.trySendAction( + action = VaultAction.MasterPasswordRepromptSubmit( + item = vaultItem, + password = "", + ), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `clicking a favorite item with password prompt should prompt for password before sending VaultItemClick upon Submit`() { + val itemText = "Test Item" + val userName = "Bitwarden" + val password = "password1234" + val vaultItem = VaultState.ViewState.VaultItem.Login( + id = "12345", + name = itemText.asText(), + username = userName.asText(), + overflowOptions = emptyList(), + shouldShowMasterPasswordReprompt = true, + ) + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + favoriteItems = listOf(vaultItem), + ), + ) + } + + composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText)) + composeTestRule + .onNodeWithText(text = itemText) + .assertTextEquals(itemText, userName) + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText( + text = "This action is protected, to continue please re-enter your master " + + "password to verify your identity.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performTextInput(text = password) + composeTestRule + .onAllNodesWithText(text = "Submit") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction( + action = VaultAction.MasterPasswordRepromptSubmit( + item = vaultItem, + password = password, + ), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `clicking a favorite item overflow with password prompt should prompt for password before dismissing upon Cancel`() { + val itemText = "Test Item" + val userName = "Bitwarden" + val cipherId = "12345" + val vaultItem = VaultState.ViewState.VaultItem.Login( + id = cipherId, + name = itemText.asText(), + username = userName.asText(), + overflowOptions = persistentListOf( + ListingItemOverflowAction.VaultAction.ViewClick( + cipherId = cipherId, + cipherType = CipherType.LOGIN, + requiresPasswordReprompt = true, + ), + ), + shouldShowMasterPasswordReprompt = true, + ) + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + favoriteItems = listOf(vaultItem), + ), + ) + } + + composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText)) + composeTestRule + .onNodeWithText(text = itemText) + .onChildren() + .filterToOne(hasContentDescription(value = "Options")) + .performClick() + + composeTestRule + .onNodeWithText(text = "View") + .assert(hasAnyAncestor(isDialog())) + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText( + text = "This action is protected, to continue please re-enter your master " + + "password to verify your identity.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify(exactly = 0) { + viewModel.trySendAction( + action = VaultAction.MasterPasswordRepromptSubmit( + item = vaultItem, + password = "", + ), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `clicking a favorite item overflow with password prompt should prompt for password before sending VaultItemClick upon Submit`() { + val itemText = "Test Item" + val userName = "Bitwarden" + val password = "password1234" + val cipherId = "12345" + val overflowAction = ListingItemOverflowAction.VaultAction.ViewClick( + cipherId = cipherId, + cipherType = CipherType.LOGIN, + requiresPasswordReprompt = true, + ) + val vaultItem = VaultState.ViewState.VaultItem.Login( + id = cipherId, + name = itemText.asText(), + username = userName.asText(), + overflowOptions = persistentListOf(overflowAction), + shouldShowMasterPasswordReprompt = true, + ) + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + favoriteItems = persistentListOf(vaultItem), + ), + ) + } + + composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText)) + composeTestRule + .onNodeWithText(text = itemText) + .onChildren() + .filterToOne(hasContentDescription(value = "Options")) + .performClick() + + composeTestRule + .onNodeWithText(text = "View") + .assert(hasAnyAncestor(isDialog())) + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText( + text = "This action is protected, to continue please re-enter your master " + + "password to verify your identity.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performTextInput(text = password) + composeTestRule + .onAllNodesWithText(text = "Submit") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction( + action = VaultAction.OverflowMasterPasswordRepromptSubmit( + overflowAction = overflowAction, + password = password, + ), + ) + } + } + @Test fun `clicking a folder item should send FolderClick with the correct item`() { val folderText = "Test Folder" @@ -973,7 +1235,7 @@ class VaultScreenTest : BitwardenComposeTest() { } @Test - fun `clicking a no folder item should send VaultItemClick with the correct item`() { + fun `clicking a no folder item without password prompt should send VaultItemClick`() { val itemText = "Test Item" val userName = "BitWarden" val vaultItem = VaultState.ViewState.VaultItem.Login( @@ -1001,6 +1263,262 @@ class VaultScreenTest : BitwardenComposeTest() { } } + @Suppress("MaxLineLength") + @Test + fun `clicking a no folder overflow item with password prompt should prompt for password before dismissing upon Cancel`() { + val itemText = "Test Item" + val userName = "Bitwarden" + val cipherId = "12345" + val overflowAction = ListingItemOverflowAction.VaultAction.ViewClick( + cipherId = cipherId, + cipherType = CipherType.LOGIN, + requiresPasswordReprompt = true, + ) + val vaultItem = VaultState.ViewState.VaultItem.Login( + id = cipherId, + name = itemText.asText(), + username = userName.asText(), + overflowOptions = persistentListOf(overflowAction), + shouldShowMasterPasswordReprompt = true, + ) + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + noFolderItems = listOf(vaultItem), + ), + ) + } + + composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText)) + composeTestRule + .onNodeWithText(text = itemText) + .onChildren() + .filterToOne(hasContentDescription(value = "Options")) + .performClick() + + composeTestRule + .onNodeWithText(text = "View") + .assert(hasAnyAncestor(isDialog())) + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText( + text = "This action is protected, to continue please re-enter your master " + + "password to verify your identity.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify(exactly = 0) { + viewModel.trySendAction( + action = VaultAction.OverflowMasterPasswordRepromptSubmit( + overflowAction = overflowAction, + password = "", + ), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `clicking a no folder overflow item with password prompt should prompt for password before sending VaultItemClick upon Submit`() { + val itemText = "Test Item" + val userName = "Bitwarden" + val password = "password1234" + val cipherId = "12345" + val overflowAction = ListingItemOverflowAction.VaultAction.ViewClick( + cipherId = cipherId, + cipherType = CipherType.LOGIN, + requiresPasswordReprompt = true, + ) + val vaultItem = VaultState.ViewState.VaultItem.Login( + id = cipherId, + name = itemText.asText(), + username = userName.asText(), + overflowOptions = persistentListOf(overflowAction), + shouldShowMasterPasswordReprompt = true, + ) + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + noFolderItems = listOf(vaultItem), + ), + ) + } + + composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText)) + composeTestRule + .onNodeWithText(text = itemText) + .onChildren() + .filterToOne(hasContentDescription(value = "Options")) + .performClick() + + composeTestRule + .onNodeWithText(text = "View") + .assert(hasAnyAncestor(isDialog())) + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText( + text = "This action is protected, to continue please re-enter your master " + + "password to verify your identity.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performTextInput(text = password) + composeTestRule + .onAllNodesWithText(text = "Submit") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction( + action = VaultAction.OverflowMasterPasswordRepromptSubmit( + overflowAction = overflowAction, + password = password, + ), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `clicking a no folder item with password prompt should prompt for password before dismissing upon Cancel`() { + val itemText = "Test Item" + val userName = "Bitwarden" + val vaultItem = VaultState.ViewState.VaultItem.Login( + id = "12345", + name = itemText.asText(), + username = userName.asText(), + overflowOptions = emptyList(), + shouldShowMasterPasswordReprompt = true, + ) + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + noFolderItems = listOf(vaultItem), + ), + ) + } + + composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText)) + composeTestRule + .onNodeWithText(text = itemText) + .assertTextEquals(itemText, userName) + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText( + text = "This action is protected, to continue please re-enter your master " + + "password to verify your identity.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify(exactly = 0) { + viewModel.trySendAction( + action = VaultAction.MasterPasswordRepromptSubmit( + item = vaultItem, + password = "", + ), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `clicking a no folder item with password prompt should prompt for password before sending VaultItemClick upon Submit`() { + val itemText = "Test Item" + val userName = "Bitwarden" + val password = "password1234" + val vaultItem = VaultState.ViewState.VaultItem.Login( + id = "12345", + name = itemText.asText(), + username = userName.asText(), + overflowOptions = emptyList(), + shouldShowMasterPasswordReprompt = true, + ) + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + noFolderItems = listOf(vaultItem), + ), + ) + } + + composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(itemText)) + composeTestRule + .onNodeWithText(text = itemText) + .assertTextEquals(itemText, userName) + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText( + text = "This action is protected, to continue please re-enter your master " + + "password to verify your identity.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performTextInput(text = password) + composeTestRule + .onAllNodesWithText(text = "Submit") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction( + action = VaultAction.MasterPasswordRepromptSubmit( + item = vaultItem, + password = password, + ), + ) + } + } + @Test fun `login item count should update according to state`() { val rowText = "Login" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 9100800472..53de31ab34 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -60,6 +60,7 @@ import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkStatic import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull @@ -1683,24 +1684,143 @@ class VaultViewModelTest : BaseViewModelTest() { } @Test - fun `OverflowOptionClick Vault ViewClick should emit NavigateToUrl`() = runTest { - val cipherId = "cipherId-9876" - val viewModel = createViewModel() - viewModel.eventFlow.test { - viewModel.trySendAction( - VaultAction.OverflowOptionClick( - ListingItemOverflowAction.VaultAction.ViewClick( - cipherId = cipherId, - cipherType = CipherType.LOGIN, + fun `OverflowOptionClick Vault ViewClick without reprompt should emit NavigateToUrl`() = + runTest { + val cipherId = "cipherId-9876" + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction( + VaultAction.OverflowOptionClick( + ListingItemOverflowAction.VaultAction.ViewClick( + cipherId = cipherId, + cipherType = CipherType.LOGIN, + requiresPasswordReprompt = false, + ), ), + ) + assertEquals( + VaultEvent.NavigateToVaultItem( + itemId = cipherId, + type = VaultItemCipherType.LOGIN, + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `OverflowMasterPasswordRepromptSubmit for a request Error should show a generic error dialog`() = + runTest { + val password = "password" + val error = Throwable("Fail!") + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Error(error = error) + + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + + viewModel.trySendAction( + VaultAction.OverflowMasterPasswordRepromptSubmit( + overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick( + password = password, + requiresPasswordReprompt = true, + cipherId = "cipherId", + ), + password = password, + ), + ) + + assertEquals( + DEFAULT_STATE.copy( + dialog = VaultState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + error = error, + ), + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `OverflowMasterPasswordRepromptSubmit for a request Success with an invalid password should show an invalid password dialog`() = + runTest { + val password = "password" + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Success(isValid = false) + + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + + viewModel.trySendAction( + VaultAction.OverflowMasterPasswordRepromptSubmit( + overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick( + password = password, + requiresPasswordReprompt = true, + cipherId = "cipherId", + ), + password = password, + ), + ) + + assertEquals( + DEFAULT_STATE.copy( + dialog = VaultState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.invalid_master_password.asText(), + ), + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `OverflowMasterPasswordRepromptSubmit for a request Success with a valid password should continue the action`() = + runTest { + val password = "password" + val cipherId = "cipherId" + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Success(isValid = true) + + val viewModel = createViewModel() + + viewModel.trySendAction( + VaultAction.OverflowMasterPasswordRepromptSubmit( + overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick( + password = password, + requiresPasswordReprompt = true, + cipherId = cipherId, + ), + password = password, ), ) - assertEquals( - VaultEvent.NavigateToVaultItem(itemId = cipherId, type = VaultItemCipherType.LOGIN), - awaitItem(), - ) + + verify(exactly = 1) { + clipboardManager.setText( + text = password, + toastDescriptorOverride = R.string.password.asText(), + ) + organizationEventManager.trackEvent( + event = OrganizationEvent.CipherClientCopiedPassword(cipherId = cipherId), + ) + } } - } @Test fun `MasterPasswordRepromptSubmit for a request Error should show a generic error dialog`() = @@ -1720,10 +1840,12 @@ class VaultViewModelTest : BaseViewModelTest() { viewModel.trySendAction( VaultAction.MasterPasswordRepromptSubmit( - overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick( - password = password, - requiresPasswordReprompt = true, - cipherId = "cipherId", + item = VaultState.ViewState.VaultItem.Login( + id = "cipherId", + name = "name".asText(), + shouldShowMasterPasswordReprompt = true, + username = null, + overflowOptions = persistentListOf(), ), password = password, ), @@ -1760,10 +1882,11 @@ class VaultViewModelTest : BaseViewModelTest() { viewModel.trySendAction( VaultAction.MasterPasswordRepromptSubmit( - overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick( - password = password, - requiresPasswordReprompt = true, - cipherId = "cipherId", + item = VaultState.ViewState.VaultItem.Card( + id = "cipherId", + name = "name".asText(), + shouldShowMasterPasswordReprompt = true, + overflowOptions = persistentListOf(), ), password = password, ), @@ -1786,31 +1909,29 @@ class VaultViewModelTest : BaseViewModelTest() { fun `MasterPasswordRepromptSubmit for a request Success with a valid password should continue the action`() = runTest { val password = "password" - val cipherId = "cipherId" + val item = VaultState.ViewState.VaultItem.Identity( + id = "cipherId", + name = "name".asText(), + shouldShowMasterPasswordReprompt = true, + fullName = null, + overflowOptions = persistentListOf(), + ) coEvery { authRepository.validatePassword(password = password) } returns ValidatePasswordResult.Success(isValid = true) val viewModel = createViewModel() - viewModel.trySendAction( - VaultAction.MasterPasswordRepromptSubmit( - overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick( + viewModel.eventFlow.test { + viewModel.trySendAction( + VaultAction.MasterPasswordRepromptSubmit( + item = item, password = password, - requiresPasswordReprompt = true, - cipherId = cipherId, ), - password = password, - ), - ) - - verify(exactly = 1) { - clipboardManager.setText( - text = password, - toastDescriptorOverride = R.string.password.asText(), ) - organizationEventManager.trackEvent( - event = OrganizationEvent.CipherClientCopiedPassword(cipherId = cipherId), + assertEquals( + VaultEvent.NavigateToVaultItem(itemId = item.id, type = item.type), + awaitItem(), ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt index cf7f971629..b244d980f5 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -994,6 +994,7 @@ private fun createMockSshKeyVaultItem(number: Int): VaultState.ViewState.VaultIt ListingItemOverflowAction.VaultAction.ViewClick( cipherId = "mockId-$number", cipherType = CipherType.SSH_KEY, + requiresPasswordReprompt = true, ), ListingItemOverflowAction.VaultAction.EditClick( cipherId = "mockId-$number", diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt index 06e918687a..f6e532163c 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.repository.model.Environment import com.bitwarden.data.repository.util.baseIconUrl @@ -263,12 +264,12 @@ class VerificationCodeScreenTest : BitwardenComposeTest() { } @Test - fun `clicking on a display item should send ItemClick action`() { + fun `clicking on a display item without hideAuthCode should send ItemClick action`() { mutableStateFlow.update { it.copy( viewState = VerificationCodeState.ViewState.Content( verificationCodeDisplayItems = listOf( - createDisplayItem(number = 1), + createDisplayItem(number = 1, hideAuthCode = false), ), ), ) @@ -284,6 +285,104 @@ class VerificationCodeScreenTest : BitwardenComposeTest() { } } + @Suppress("MaxLineLength") + @Test + fun `clicking on a display item with hideAuthCode should prompt for password before dismissing the dialog upon cancel`() { + composeTestRule.assertNoDialogExists() + mutableStateFlow.update { + it.copy( + viewState = VerificationCodeState.ViewState.Content( + verificationCodeDisplayItems = listOf( + createDisplayItem(number = 1, hideAuthCode = true), + ), + ), + ) + } + + composeTestRule + .onNodeWithText(text = "Label 1") + .assertIsDisplayed() + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText( + text = "This action is protected, to continue please re-enter your master " + + "password to verify your identity.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify(exactly = 0) { + viewModel.trySendAction(action = any()) + } + composeTestRule.assertNoDialogExists() + } + + @Suppress("MaxLineLength") + @Test + fun `clicking on a display item with hideAuthCode should prompt for password before sending MasterPasswordSubmit upon submit`() { + composeTestRule.assertNoDialogExists() + mutableStateFlow.update { + it.copy( + viewState = VerificationCodeState.ViewState.Content( + verificationCodeDisplayItems = listOf( + createDisplayItem(number = 1, hideAuthCode = true), + ), + ), + ) + } + + composeTestRule + .onNodeWithText(text = "Label 1") + .assertIsDisplayed() + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText( + text = "This action is protected, to continue please re-enter your master " + + "password to verify your identity.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performTextInput(text = "password1234") + composeTestRule + .onAllNodesWithText(text = "Submit") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + verify { + viewModel.trySendAction( + action = VerificationCodeAction.MasterPasswordSubmit( + cipherId = "1", + password = "password1234", + ), + ) + } + composeTestRule.assertNoDialogExists() + } + @Test fun `clicking on copy button should send CopyClick action`() { mutableStateFlow.update { @@ -374,6 +473,27 @@ class VerificationCodeScreenTest : BitwardenComposeTest() { .assertIsDisplayed() .assert(hasAnyAncestor(isDialog())) } + + @Test + fun `error dialog should be displayed according to state`() { + val errorMessage = "error" + composeTestRule.assertNoDialogExists() + composeTestRule.onNodeWithText(text = errorMessage).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = VerificationCodeState.DialogState.Error( + title = null, + message = errorMessage.asText(), + ), + ) + } + + composeTestRule + .onNodeWithText(text = errorMessage) + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + } } private fun createDisplayItem( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModelTest.kt index b1f17b0789..fc8256e2da 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModelTest.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.vault.feature.verificationcode import android.net.Uri +import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.data.repository.model.Environment @@ -13,6 +14,7 @@ import com.bitwarden.vault.CipherRepromptType 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.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -22,6 +24,8 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData import com.x8bit.bitwarden.ui.vault.feature.verificationcode.util.createVerificationCodeItem +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -129,6 +133,86 @@ class VerificationCodeViewModelTest : BaseViewModelTest() { } } + @Test + fun `MasterPasswordSubmit should validate the password and display an error on failure`() { + val viewModel = createViewModel() + val password = "password_1234" + val throwable = Throwable("Error!") + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Error(error = throwable) + viewModel.trySendAction( + action = VerificationCodeAction.MasterPasswordSubmit( + cipherId = "cipher_id", + password = password, + ), + ) + assertEquals( + viewModel.stateFlow.value, + initialState.copy( + dialogState = VerificationCodeState.DialogState.Error( + title = null, + message = R.string.generic_error_message.asText(), + throwable = throwable, + ), + ), + ) + coVerify(exactly = 1) { + authRepository.validatePassword(password = password) + } + } + + @Suppress("MaxLineLength") + @Test + fun `MasterPasswordSubmit should validate the password and display an error on invalid password`() { + val viewModel = createViewModel() + val password = "password_1234" + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Success(isValid = false) + viewModel.trySendAction( + action = VerificationCodeAction.MasterPasswordSubmit( + cipherId = "cipher_id", + password = password, + ), + ) + assertEquals( + viewModel.stateFlow.value, + initialState.copy( + dialogState = VerificationCodeState.DialogState.Error( + title = null, + message = R.string.invalid_master_password.asText(), + ), + ), + ) + coVerify(exactly = 1) { + authRepository.validatePassword(password = password) + } + } + + @Test + fun `MasterPasswordSubmit should validate the password and emit NavigateToVaultItem`() = + runTest { + val viewModel = createViewModel() + val password = "password_1234" + val cipherId = "cipher_id" + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Success(isValid = true) + viewModel.eventFlow.test { + viewModel.trySendAction( + action = VerificationCodeAction.MasterPasswordSubmit( + cipherId = cipherId, + password = password, + ), + ) + assertEquals(VerificationCodeEvent.NavigateToVaultItem(id = cipherId), awaitItem()) + } + coVerify(exactly = 1) { + authRepository.validatePassword(password = password) + } + } + @Test fun `LockClick should call lockVaultForCurrentUser`() { every { vaultRepository.lockVaultForCurrentUser(any()) } just runs @@ -481,6 +565,22 @@ class VerificationCodeViewModelTest : BaseViewModelTest() { ) } + @Test + fun `DismissDialog should clear the dialog state`() = runTest { + val defaultState = initialState.copy( + dialogState = VerificationCodeState.DialogState.Error( + title = null, + message = "Test".asText(), + ), + ) + val viewModel = createViewModel(state = defaultState) + viewModel.stateFlow.test { + assertEquals(defaultState, awaitItem()) + viewModel.trySendAction(VerificationCodeAction.DismissDialog) + assertEquals(initialState, awaitItem()) + } + } + private fun setupMockUri() { mockkStatic(Uri::class) val uriMock = mockk() @@ -488,8 +588,13 @@ class VerificationCodeViewModelTest : BaseViewModelTest() { every { uriMock.host } returns "www.mockuri.com" } - private fun createViewModel(): VerificationCodeViewModel = + private fun createViewModel( + state: VerificationCodeState? = null, + ): VerificationCodeViewModel = VerificationCodeViewModel( + savedStateHandle = SavedStateHandle().apply { + set(key = "state", value = state) + }, clipboardManager = clipboardManager, vaultRepository = vaultRepository, environmentRepository = environmentRepository,