diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index dacfc583a0..5ef7a82ee5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -114,7 +114,14 @@ fun VaultItemScreen( { viewModel.trySendAction(VaultItemAction.Common.DismissDialogClick) } }, onSubmitMasterPassword = remember(viewModel) { - { viewModel.trySendAction(VaultItemAction.Common.MasterPasswordSubmit(it)) } + { masterPassword, action -> + viewModel.trySendAction( + VaultItemAction.Common.MasterPasswordSubmit( + masterPassword = masterPassword, + action = action, + ), + ) + } }, ) @@ -271,7 +278,7 @@ fun VaultItemScreen( private fun VaultItemDialogs( dialog: VaultItemState.DialogState?, onDismissRequest: () -> Unit, - onSubmitMasterPassword: (String) -> Unit, + onSubmitMasterPassword: (masterPassword: String, action: PasswordRepromptAction) -> Unit, ) { when (dialog) { is VaultItemState.DialogState.Generic -> BitwardenBasicDialog( @@ -286,9 +293,9 @@ private fun VaultItemDialogs( visibilityState = LoadingDialogState.Shown(text = dialog.message), ) - VaultItemState.DialogState.MasterPasswordDialog -> { + is VaultItemState.DialogState.MasterPasswordDialog -> { BitwardenMasterPasswordDialog( - onConfirmClick = onSubmitMasterPassword, + onConfirmClick = { onSubmitMasterPassword(it, dialog.action) }, onDismissRequest = onDismissRequest, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index 34df9d74b3..60fb9daa11 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -40,7 +40,7 @@ private const val KEY_STATE = "state" /** * ViewModel responsible for handling user interactions in the vault item screen */ -@Suppress("TooManyFunctions") +@Suppress("LargeClass", "TooManyFunctions") @HiltViewModel class VaultItemViewModel @Inject constructor( savedStateHandle: SavedStateHandle, @@ -141,7 +141,11 @@ class VaultItemViewModel @Inject constructor( onContent { content -> if (content.common.requiresReprompt) { mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.EditClick, + ), + ) } return@onContent } @@ -160,7 +164,7 @@ class VaultItemViewModel @Inject constructor( } viewModelScope.launch { val result = authRepository.validatePassword(action.masterPassword) - sendAction(VaultItemAction.Internal.ValidatePasswordReceive(result)) + sendAction(VaultItemAction.Internal.ValidatePasswordReceive(result, action.action)) } } @@ -175,7 +179,11 @@ class VaultItemViewModel @Inject constructor( onContent { content -> if (content.common.requiresReprompt) { mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.CopyClick(action.field), + ), + ) } return@onContent } @@ -195,7 +203,14 @@ class VaultItemViewModel @Inject constructor( onContent { content -> if (content.common.requiresReprompt) { mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.ViewHiddenFieldClicked( + field = action.field, + isVisible = action.isVisible, + ), + ), + ) } return@onContent } @@ -218,20 +233,51 @@ class VaultItemViewModel @Inject constructor( } private fun handleAttachmentsClick() { - sendEvent(VaultItemEvent.NavigateToAttachments(itemId = state.vaultItemId)) + onContent { content -> + if (content.common.requiresReprompt) { + mutableStateFlow.update { + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.AttachmentsClick, + ), + ) + } + return@onContent + } + sendEvent(VaultItemEvent.NavigateToAttachments(itemId = state.vaultItemId)) + } } private fun handleCloneClick() { - sendEvent( - VaultItemEvent.NavigateToAddEdit( - itemId = state.vaultItemId, - isClone = true, - ), - ) + onContent { content -> + if (content.common.requiresReprompt) { + mutableStateFlow.update { + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.CloneClick, + ), + ) + } + return@onContent + } + sendEvent(VaultItemEvent.NavigateToAddEdit(itemId = state.vaultItemId, isClone = true)) + } } private fun handleMoveToOrganizationClick() { - sendEvent(VaultItemEvent.NavigateToMoveToOrganization(itemId = state.vaultItemId)) + onContent { content -> + if (content.common.requiresReprompt) { + mutableStateFlow.update { + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.MoveToOrganizationClick, + ), + ) + } + return@onContent + } + sendEvent(VaultItemEvent.NavigateToMoveToOrganization(itemId = state.vaultItemId)) + } } private fun handleCollectionsClick() { @@ -239,29 +285,36 @@ class VaultItemViewModel @Inject constructor( } private fun handleConfirmDeleteClick() { - mutableStateFlow.update { - it.copy( - dialog = VaultItemState.DialogState.Loading( - R.string.soft_deleting.asText(), - ), - ) - } onContent { content -> - content - .common - .currentCipher - ?.let { cipher -> - viewModelScope.launch { - trySendAction( - VaultItemAction.Internal.DeleteCipherReceive( - result = vaultRepository.softDeleteCipher( - cipherId = state.vaultItemId, - cipherView = cipher, - ), - ), - ) - } + if (content.common.requiresReprompt) { + mutableStateFlow.update { + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.DeleteClick, + ), + ) } + return@onContent + } + mutableStateFlow.update { + it.copy( + dialog = VaultItemState.DialogState.Loading( + R.string.soft_deleting.asText(), + ), + ) + } + content.common.currentCipher?.let { cipher -> + viewModelScope.launch { + trySendAction( + VaultItemAction.Internal.DeleteCipherReceive( + result = vaultRepository.softDeleteCipher( + cipherId = state.vaultItemId, + cipherView = cipher, + ), + ), + ) + } + } } } @@ -347,13 +400,17 @@ class VaultItemViewModel @Inject constructor( private fun handleCopyPasswordClick() { onLoginContent { content, login -> + val password = requireNotNull(login.passwordData?.password) if (content.common.requiresReprompt) { mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.CopyClick(value = password), + ), + ) } return@onLoginContent } - val password = requireNotNull(login.passwordData?.password) clipboardManager.setText(text = password) } } @@ -370,13 +427,7 @@ class VaultItemViewModel @Inject constructor( } private fun handleCopyUsernameClick() { - onLoginContent { content, login -> - if (content.common.requiresReprompt) { - mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) - } - return@onLoginContent - } + onLoginContent { _, login -> val username = requireNotNull(login.username) clipboardManager.setText(text = username) } @@ -392,7 +443,11 @@ class VaultItemViewModel @Inject constructor( onContent { content -> if (content.common.requiresReprompt) { mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.PasswordHistoryClick, + ), + ) } return@onContent } @@ -406,7 +461,13 @@ class VaultItemViewModel @Inject constructor( onLoginContent { content, login -> if (content.common.requiresReprompt) { mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.ViewPasswordClick( + isVisible = action.isVisible, + ), + ), + ) } return@onLoginContent } @@ -437,26 +498,34 @@ class VaultItemViewModel @Inject constructor( private fun handleCopyNumberClick() { onCardContent { content, card -> + val number = requireNotNull(card.number) if (content.common.requiresReprompt) { mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.CopyClick(value = number), + ), + ) } return@onCardContent } - val number = requireNotNull(card.number) clipboardManager.setText(text = number) } } private fun handleCopySecurityCodeClick() { onCardContent { content, card -> + val securityCode = requireNotNull(card.securityCode) if (content.common.requiresReprompt) { mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.CopyClick(value = securityCode), + ), + ) } return@onCardContent } - val securityCode = requireNotNull(card.securityCode) clipboardManager.setText(text = securityCode) } } @@ -467,6 +536,7 @@ class VaultItemViewModel @Inject constructor( private fun handleInternalAction(action: VaultItemAction.Internal) { when (action) { + is VaultItemAction.Internal.CopyValue -> handleCopyValue(action) is VaultItemAction.Internal.PasswordBreachReceive -> handlePasswordBreachReceive(action) is VaultItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) is VaultItemAction.Internal.ValidatePasswordReceive -> handleValidatePasswordReceive( @@ -478,6 +548,10 @@ class VaultItemViewModel @Inject constructor( } } + private fun handleCopyValue(action: VaultItemAction.Internal.CopyValue) { + clipboardManager.setText(action.value) + } + private fun handlePasswordBreachReceive( action: VaultItemAction.Internal.PasswordBreachReceive, ) { @@ -592,6 +666,7 @@ class VaultItemViewModel @Inject constructor( ), ) } + trySendAction(action.repromptAction.vaultItemAction) } } else { mutableStateFlow.update { @@ -949,7 +1024,9 @@ data class VaultItemState( * Displays the master password dialog to the user. */ @Parcelize - data object MasterPasswordDialog : DialogState() + data class MasterPasswordDialog( + val action: PasswordRepromptAction, + ) : DialogState() } } @@ -1054,6 +1131,7 @@ sealed class VaultItemAction { */ data class MasterPasswordSubmit( val masterPassword: String, + val action: PasswordRepromptAction, ) : Common() /** @@ -1181,6 +1259,13 @@ sealed class VaultItemAction { * Models actions that the [VaultItemViewModel] itself might send. */ sealed class Internal : VaultItemAction() { + /** + * Copies the given [value] to the clipboard. + */ + data class CopyValue( + val value: String, + ) : Internal() + /** * Indicates that the password breach results have been received. */ @@ -1201,6 +1286,7 @@ sealed class VaultItemAction { */ data class ValidatePasswordReceive( val result: ValidatePasswordResult, + val repromptAction: PasswordRepromptAction, ) : Internal() /** @@ -1218,3 +1304,117 @@ 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.ItemType.Login.PasswordHistoryClick] + * upon password validation. + */ + @Parcelize + data object PasswordHistoryClick : PasswordRepromptAction() { + override val vaultItemAction: VaultItemAction + get() = VaultItemAction.ItemType.Login.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 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.ConfirmDeleteClick + } + + /** + * 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.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, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index fd1d938455..d4d977f122 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -186,7 +186,11 @@ class VaultItemScreenTest : BaseComposeTest() { composeTestRule.onNodeWithText("Master password confirmation").assertDoesNotExist() mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.DeleteClick, + ), + ) } composeTestRule @@ -198,8 +202,13 @@ class VaultItemScreenTest : BaseComposeTest() { @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) + it.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = passwordRepromptAction, + ), + ) } composeTestRule.onNodeWithText("Master password").performTextInput(enteredPassword) @@ -209,7 +218,12 @@ class VaultItemScreenTest : BaseComposeTest() { .performClick() verify { - viewModel.trySendAction(VaultItemAction.Common.MasterPasswordSubmit(enteredPassword)) + viewModel.trySendAction( + VaultItemAction.Common.MasterPasswordSubmit( + masterPassword = enteredPassword, + action = passwordRepromptAction, + ), + ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index bf2970a09f..c45e53036d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.vault.feature.item import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import app.cash.turbine.turbineScope import com.bitwarden.core.CipherView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository @@ -42,6 +43,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +@Suppress("LargeClass") class VaultItemViewModelTest : BaseViewModelTest() { private val mutableVaultItemFlow = MutableStateFlow>(DataState.Loading) @@ -124,17 +126,54 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(initialState.copy(dialog = null), viewModel.stateFlow.value) } + @Test + fun `ConfirmDeleteClick should show password dialog when re-prompt is required`() = + runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) + val mockCipherView = mockk { + every { + toViewState( + isPremiumUser = true, + totpCodeItemData = null, + ) + } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick) + assertEquals( + loginState.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.DeleteClick, + ), + ), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState( + isPremiumUser = true, + totpCodeItemData = null, + ) + } + } + @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), + ) val mockCipherView = mockk { every { toViewState( isPremiumUser = true, totpCodeItemData = createTotpCodeData(), ) - } returns DEFAULT_VIEW_STATE + } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = @@ -166,13 +205,16 @@ 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), + ) val mockCipherView = mockk { every { toViewState( isPremiumUser = true, totpCodeItemData = null, ) - } returns DEFAULT_VIEW_STATE + } returns loginViewState } mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) @@ -189,7 +231,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals( DEFAULT_STATE.copy( - viewState = DEFAULT_VIEW_STATE, + viewState = loginViewState, dialog = VaultItemState.DialogState.Generic( message = R.string.generic_error_message.asText(), ), @@ -303,7 +345,11 @@ class VaultItemViewModelTest : BaseViewModelTest() { viewModel.trySendAction(VaultItemAction.Common.EditClick) assertEquals( - loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + loginState.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.EditClick, + ), + ), viewModel.stateFlow.value, ) } @@ -363,16 +409,23 @@ class VaultItemViewModelTest : BaseViewModelTest() { val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val viewModel = createViewModel(state = loginState) - viewModel.stateFlow.test { - assertEquals(loginState, awaitItem()) - viewModel.trySendAction(VaultItemAction.Common.MasterPasswordSubmit(password)) + turbineScope { + val stateFlow = viewModel.stateFlow.testIn(backgroundScope) + val eventFlow = viewModel.eventFlow.testIn(backgroundScope) + 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(), ), ), - awaitItem(), + stateFlow.awaitItem(), ) assertEquals( loginState.copy( @@ -380,7 +433,14 @@ class VaultItemViewModelTest : BaseViewModelTest() { common = DEFAULT_COMMON.copy(requiresReprompt = false), ), ), - awaitItem(), + stateFlow.awaitItem(), + ) + assertEquals( + VaultItemEvent.NavigateToAddEdit( + itemId = DEFAULT_STATE.vaultItemId, + isClone = false, + ), + eventFlow.awaitItem(), ) } } @@ -412,7 +472,12 @@ class VaultItemViewModelTest : BaseViewModelTest() { viewModel.stateFlow.test { assertEquals(loginState, awaitItem()) - viewModel.trySendAction(VaultItemAction.Common.MasterPasswordSubmit(password)) + viewModel.trySendAction( + VaultItemAction.Common.MasterPasswordSubmit( + masterPassword = password, + action = PasswordRepromptAction.DeleteClick, + ), + ) assertEquals( loginState.copy( dialog = VaultItemState.DialogState.Loading( @@ -458,7 +523,12 @@ class VaultItemViewModelTest : BaseViewModelTest() { viewModel.stateFlow.test { assertEquals(loginState, awaitItem()) - viewModel.trySendAction(VaultItemAction.Common.MasterPasswordSubmit(password)) + viewModel.trySendAction( + VaultItemAction.Common.MasterPasswordSubmit( + masterPassword = password, + action = PasswordRepromptAction.DeleteClick, + ), + ) assertEquals( loginState.copy( dialog = VaultItemState.DialogState.Loading( @@ -509,7 +579,11 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.CopyCustomHiddenFieldClick("field")) assertEquals( - loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + loginState.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.CopyClick(value = "field"), + ), + ), viewModel.stateFlow.value, ) @@ -566,6 +640,12 @@ class VaultItemViewModelTest : BaseViewModelTest() { 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( + name = "hidden", + value = "value", + isCopyable = true, + isVisible = false, + ) val mockCipherView = mockk { every { toViewState( @@ -580,17 +660,19 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction( VaultItemAction.Common.HiddenFieldVisibilityClicked( - field = VaultItemState.ViewState.Content.Common.Custom.HiddenField( - name = "hidden", - value = "value", - isCopyable = true, - isVisible = false, - ), + field = field, isVisible = true, ), ) assertEquals( - loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + loginState.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.ViewHiddenFieldClicked( + field = field, + isVisible = true, + ), + ), + ), viewModel.stateFlow.value, ) @@ -661,43 +743,198 @@ class VaultItemViewModelTest : BaseViewModelTest() { } @Test - fun `on AttachmentsClick should emit NavigateToAttachments`() = runTest { - val viewModel = createViewModel(state = DEFAULT_STATE) - viewModel.eventFlow.test { + fun `on AttachmentsClick should show password dialog when re-prompt is required`() = + runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) + val mockCipherView = mockk { + every { + toViewState( + isPremiumUser = true, + totpCodeItemData = null, + ) + } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + + assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.AttachmentsClick) assertEquals( - VaultItemEvent.NavigateToAttachments(itemId = VAULT_ITEM_ID), - awaitItem(), - ) - } - } - - @Test - fun `on CloneClick should emit NavigateToAddEdit with isClone set to true`() = runTest { - val viewModel = createViewModel(state = DEFAULT_STATE) - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Common.CloneClick) - assertEquals( - VaultItemEvent.NavigateToAddEdit( - itemId = VAULT_ITEM_ID, - isClone = true, + loginState.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.AttachmentsClick, + ), ), - awaitItem(), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState( + isPremiumUser = true, + totpCodeItemData = null, + ) + } + } + + @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) + val mockCipherView = mockk { + every { + toViewState( + isPremiumUser = true, + totpCodeItemData = null, + ) + } returns loginViewState + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + + 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 password dialog when re-prompt is required`() = runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) + val mockCipherView = mockk { + every { + toViewState( + isPremiumUser = true, + totpCodeItemData = null, + ) + } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction(VaultItemAction.Common.CloneClick) + assertEquals( + loginState.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.CloneClick, + ), + ), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState( + isPremiumUser = true, + totpCodeItemData = null, ) } } + @Suppress("MaxLineLength") @Test - fun `on MoveToOrganizationClick should emit NavigateToMoveToOrganization`() = runTest { - val viewModel = createViewModel(state = DEFAULT_STATE) - viewModel.eventFlow.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), + ) + val loginState = DEFAULT_STATE.copy(viewState = loginViewState) + val mockCipherView = mockk { + every { + toViewState( + isPremiumUser = true, + totpCodeItemData = null, + ) + } returns loginViewState + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + + assertEquals(loginState, viewModel.stateFlow.value) + + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Common.CloneClick) + assertEquals( + VaultItemEvent.NavigateToAddEdit( + itemId = VAULT_ITEM_ID, + isClone = true, + ), + awaitItem(), + ) + } + } + + @Test + fun `on MoveToOrganizationClick should show password dialog when re-prompt is required`() = + runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) + val mockCipherView = mockk { + every { + toViewState( + isPremiumUser = true, + totpCodeItemData = null, + ) + } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + + assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.Common.MoveToOrganizationClick) assertEquals( - VaultItemEvent.NavigateToMoveToOrganization(itemId = VAULT_ITEM_ID), - awaitItem(), + loginState.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.MoveToOrganizationClick, + ), + ), + viewModel.stateFlow.value, ) + + verify(exactly = 1) { + mockCipherView.toViewState( + isPremiumUser = true, + totpCodeItemData = null, + ) + } + } + + @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) + val mockCipherView = mockk { + every { + toViewState( + isPremiumUser = true, + totpCodeItemData = null, + ) + } returns loginViewState + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + + 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 { @@ -794,7 +1031,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyPasswordClick) assertEquals( - loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + loginState.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.CopyClick( + value = DEFAULT_LOGIN_PASSWORD, + ), + ), + ), viewModel.stateFlow.value, ) @@ -862,39 +1105,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { } @Test - fun `on CopyUsernameClick should show password dialog when re-prompt is required`() = - runTest { - val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE) - val mockCipherView = mockk { - every { - toViewState( - isPremiumUser = true, - totpCodeItemData = createTotpCodeData(), - ) - } returns DEFAULT_VIEW_STATE - } - mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) - mutableAuthCodeItemFlow.value = - DataState.Loaded(data = createVerificationCodeItem()) - - assertEquals(loginState, viewModel.stateFlow.value) - viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUsernameClick) - assertEquals( - loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), - viewModel.stateFlow.value, - ) - - verify(exactly = 1) { - mockCipherView.toViewState( - isPremiumUser = true, - totpCodeItemData = createTotpCodeData(), - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `on CopyUsernameClick should call setText on ClipboardManager when re-prompt is not required`() { + fun `on CopyUsernameClick should call setText on ClipboardManager`() { val mockCipherView = mockk { every { toViewState( @@ -946,7 +1157,11 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.ItemType.Login.PasswordHistoryClick) assertEquals( - loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + loginState.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.PasswordHistoryClick, + ), + ), viewModel.stateFlow.value, ) @@ -1015,11 +1230,15 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(loginState, viewModel.stateFlow.value) viewModel.trySendAction( VaultItemAction.ItemType.Login.PasswordVisibilityClicked( - true, + isVisible = true, ), ) assertEquals( - loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + loginState.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.ViewPasswordClick(isVisible = true), + ), + ), viewModel.stateFlow.value, ) @@ -1109,7 +1328,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(cardState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick) assertEquals( - cardState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + cardState.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.CopyClick( + value = requireNotNull(DEFAULT_CARD_TYPE.number), + ), + ), + ), viewModel.stateFlow.value, ) @@ -1168,7 +1393,13 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(cardState, viewModel.stateFlow.value) viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick) assertEquals( - cardState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + cardState.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.CopyClick( + value = requireNotNull(DEFAULT_CARD_TYPE.securityCode), + ), + ), + ), viewModel.stateFlow.value, )