BIT-1646, BIT-1647: Launch action after password validation (#852)

This commit is contained in:
David Perez 2024-01-29 17:01:07 -06:00 committed by Álison Fernandes
parent 20dd839923
commit d12776483d
4 changed files with 592 additions and 140 deletions

View file

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

View file

@ -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,27 +233,69 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleAttachmentsClick() {
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.AttachmentsClick,
),
)
}
return@onContent
}
sendEvent(VaultItemEvent.NavigateToAttachments(itemId = state.vaultItemId))
}
}
private fun handleCloneClick() {
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() {
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() {
sendEvent(VaultItemEvent.NavigateToCollections(itemId = state.vaultItemId))
}
private fun handleConfirmDeleteClick() {
onContent { content ->
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(
@ -246,11 +303,7 @@ class VaultItemViewModel @Inject constructor(
),
)
}
onContent { content ->
content
.common
.currentCipher
?.let { cipher ->
content.common.currentCipher?.let { cipher ->
viewModelScope.launch {
trySendAction(
VaultItemAction.Internal.DeleteCipherReceive(
@ -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,
)
}
}

View file

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

View file

@ -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<CipherView?>>(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<CipherView> {
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<CipherView> {
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<CipherView> {
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<CipherView> {
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,8 +743,60 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
@Test
fun `on AttachmentsClick should emit NavigateToAttachments`() = runTest {
val viewModel = createViewModel(state = DEFAULT_STATE)
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<CipherView> {
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(
loginState.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.AttachmentsClick,
),
),
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<CipherView> {
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(
@ -673,8 +807,59 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
@Test
fun `on CloneClick should emit NavigateToAddEdit with isClone set to true`() = runTest {
val viewModel = createViewModel(state = DEFAULT_STATE)
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<CipherView> {
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 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<CipherView> {
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(
@ -688,8 +873,60 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
@Test
fun `on MoveToOrganizationClick should emit NavigateToMoveToOrganization`() = runTest {
val viewModel = createViewModel(state = DEFAULT_STATE)
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<CipherView> {
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(
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<CipherView> {
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(
@ -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<CipherView> {
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<CipherView> {
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,
)