BIT-1683: Show master password reprompts on Search Screen (#925)

This commit is contained in:
Brian Yencho 2024-01-31 23:29:41 -06:00 committed by Álison Fernandes
parent 81c78fc115
commit 3fe0950983
6 changed files with 488 additions and 76 deletions

View file

@ -133,6 +133,34 @@ fun SearchContent(
showConfirmationDialog = option
}
is ListingItemOverflowAction.VaultAction.EditClick -> {
if (it.shouldDisplayMasterPasswordReprompt) {
masterPasswordRepromptData =
MasterPasswordRepromptData(
cipherId = it.id,
type = MasterPasswordRepromptData.Type.Edit,
)
} else {
searchHandlers.onOverflowItemClick(option)
}
}
is ListingItemOverflowAction.VaultAction.CopyPasswordClick -> {
if (it.shouldDisplayMasterPasswordReprompt) {
masterPasswordRepromptData =
MasterPasswordRepromptData(
cipherId = it.id,
type = MasterPasswordRepromptData
.Type
.CopyPassword(
password = option.password,
),
)
} else {
searchHandlers.onOverflowItemClick(option)
}
}
else -> searchHandlers.onOverflowItemClick(option)
}
},
@ -179,13 +207,15 @@ private fun AutofillSelectionDialog(
)
} else {
when (type) {
MasterPasswordRepromptData.Type.AUTOFILL -> {
MasterPasswordRepromptData.Type.Autofill -> {
onAutofillItemClick(item.id)
}
MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE -> {
MasterPasswordRepromptData.Type.AutofillAndSave -> {
onAutofillAndSaveItemClick(item.id)
}
else -> Unit
}
}
}
@ -199,7 +229,7 @@ private fun AutofillSelectionDialog(
onClick = {
selectionCallback(
displayItem,
MasterPasswordRepromptData.Type.AUTOFILL,
MasterPasswordRepromptData.Type.Autofill,
)
},
)
@ -210,7 +240,7 @@ private fun AutofillSelectionDialog(
onClick = {
selectionCallback(
displayItem,
MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE,
MasterPasswordRepromptData.Type.AutofillAndSave,
)
},
)

View file

@ -517,25 +517,53 @@ class SearchViewModel @Inject constructor(
}
return
}
handleMasterPasswordRepromptData(data = action.masterPasswordRepromptData)
}
}
}
// Complete the deferred actions
when (action.masterPasswordRepromptData.type) {
MasterPasswordRepromptData.Type.AUTOFILL -> {
trySendAction(
SearchAction.AutofillItemClick(
itemId = action.masterPasswordRepromptData.cipherId,
),
)
}
private fun handleMasterPasswordRepromptData(
data: MasterPasswordRepromptData,
) {
// Complete the deferred actions
val cipherId = data.cipherId
when (val type = data.type) {
MasterPasswordRepromptData.Type.Autofill -> {
trySendAction(
SearchAction.AutofillItemClick(
itemId = cipherId,
),
)
}
MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE -> {
trySendAction(
SearchAction.AutofillAndSaveItemClick(
itemId = action.masterPasswordRepromptData.cipherId,
MasterPasswordRepromptData.Type.AutofillAndSave -> {
trySendAction(
SearchAction.AutofillAndSaveItemClick(
itemId = cipherId,
),
)
}
MasterPasswordRepromptData.Type.Edit -> {
trySendAction(
SearchAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.EditClick(
cipherId = cipherId,
),
),
)
}
is MasterPasswordRepromptData.Type.CopyPassword -> {
trySendAction(
SearchAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction
.VaultAction
.CopyPasswordClick(
password = type.password,
),
)
}
}
),
)
}
}
}
@ -1100,8 +1128,32 @@ data class MasterPasswordRepromptData(
/**
* The type of action that requires the prompt.
*/
enum class Type {
AUTOFILL,
AUTOFILL_AND_SAVE,
sealed class Type : Parcelable {
/**
* Autofill was selected.
*/
@Parcelize
data object Autofill : Type()
/**
* Autofill-and-save was selected.
*/
@Parcelize
data object AutofillAndSave : Type()
/**
* Edit was selected.
*/
@Parcelize
data object Edit : Type()
/**
* Copy password was selected.
*/
@Parcelize
data class CopyPassword(
val password: String,
) : Type()
}
}

View file

@ -190,7 +190,7 @@ private fun CipherView.toDisplayItem(
.filter {
this.login != null || (it != AutofillSelectionOption.AUTOFILL_AND_SAVE)
},
shouldDisplayMasterPasswordReprompt = isAutofill && reprompt == CipherRepromptType.PASSWORD,
shouldDisplayMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD,
)
private fun CipherView.toIconData(

View file

@ -13,6 +13,7 @@ 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
@ -23,6 +24,7 @@ import com.x8bit.bitwarden.ui.platform.feature.search.model.AutofillSelectionOpt
import com.x8bit.bitwarden.ui.platform.feature.search.util.createMockDisplayItemForCipher
import com.x8bit.bitwarden.ui.platform.feature.search.util.createMockDisplayItemForSend
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.util.assertMasterPasswordDialogDisplayed
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.isProgressBar
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
@ -285,29 +287,7 @@ class SearchScreenTest : BaseComposeTest() {
.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()
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule.assertMasterPasswordDialogDisplayed()
}
@Suppress("MaxLineLength")
@ -342,29 +322,7 @@ class SearchScreenTest : BaseComposeTest() {
.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()
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule.assertMasterPasswordDialogDisplayed()
}
@Suppress("MaxLineLength")
@ -433,7 +391,7 @@ class SearchScreenTest : BaseComposeTest() {
password = "password",
masterPasswordRepromptData = MasterPasswordRepromptData(
cipherId = "mockId-1",
type = MasterPasswordRepromptData.Type.AUTOFILL,
type = MasterPasswordRepromptData.Type.Autofill,
),
),
)
@ -469,7 +427,7 @@ class SearchScreenTest : BaseComposeTest() {
password = "password",
masterPasswordRepromptData = MasterPasswordRepromptData(
cipherId = "mockId-1",
type = MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE,
type = MasterPasswordRepromptData.Type.AutofillAndSave,
),
),
)
@ -518,6 +476,293 @@ class SearchScreenTest : BaseComposeTest() {
composeTestRule.onNodeWithText(text = "Search mockName").assertIsDisplayed()
}
@Test
fun `on cipher item overflow click should display options dialog`() {
val number = 1
mutableStateFlow.update {
it.copy(
viewState = SearchState.ViewState.Content(
displayItems = listOf(createMockDisplayItemForCipher(number = number)),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNode(isDialog())
.onChildren()
.filterToOne(hasText("mockName-$number"))
.assertIsDisplayed()
}
@Test
fun `on cipher item overflow option click should emit the appropriate action`() {
mutableStateFlow.update {
it.copy(
viewState = SearchState.ViewState.Content(
displayItems = listOf(createMockDisplayItemForCipher(number = 1)),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("View")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
SearchAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.ViewClick(
cipherId = "mockId-1",
),
),
)
}
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Edit")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
SearchAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.EditClick(
cipherId = "mockId-1",
),
),
)
}
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Copy username")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
SearchAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.CopyUsernameClick(
username = "mockUsername-1",
),
),
)
}
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Copy password")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
SearchAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick(
password = "mockPassword-1",
),
),
)
}
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Launch")
.assert(hasAnyAncestor(isDialog()))
.performScrollTo()
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
SearchAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.LaunchClick(
url = "www.mockuri1.com",
),
),
)
}
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `on cipher item overflow edit click when reprompt required should show the master password dialog`() {
mutableStateFlow.update {
it.copy(
viewState = SearchState.ViewState.Content(
displayItems = listOf(
createMockDisplayItemForCipher(number = 1)
.copy(shouldDisplayMasterPasswordReprompt = true),
),
),
)
}
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Edit")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 0) { viewModel.trySendAction(any()) }
composeTestRule.assertMasterPasswordDialogDisplayed()
}
@Suppress("MaxLineLength")
@Test
fun `clicking submit on the master password dialog for edit should close the dialog and send MasterPasswordRepromptSubmit`() {
mutableStateFlow.update {
it.copy(
viewState = SearchState.ViewState.Content(
displayItems = listOf(
createMockDisplayItemForCipher(number = 1)
.copy(shouldDisplayMasterPasswordReprompt = true),
),
),
)
}
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Edit")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.performTextInput("password")
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
SearchAction.MasterPasswordRepromptSubmit(
password = "password",
masterPasswordRepromptData = MasterPasswordRepromptData(
cipherId = "mockId-1",
type = MasterPasswordRepromptData.Type.Edit,
),
),
)
}
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `on cipher item overflow copy password click when reprompt required should show the master password dialog`() {
mutableStateFlow.update {
it.copy(
viewState = SearchState.ViewState.Content(
displayItems = listOf(
createMockDisplayItemForCipher(number = 1)
.copy(shouldDisplayMasterPasswordReprompt = true),
),
),
)
}
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Copy password")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify(exactly = 0) { viewModel.trySendAction(any()) }
composeTestRule.assertMasterPasswordDialogDisplayed()
}
@Suppress("MaxLineLength")
@Test
fun `clicking submit on the master password dialog for copy password should close the dialog and send MasterPasswordRepromptSubmit`() {
mutableStateFlow.update {
it.copy(
viewState = SearchState.ViewState.Content(
displayItems = listOf(
createMockDisplayItemForCipher(number = 1)
.copy(shouldDisplayMasterPasswordReprompt = true),
),
),
)
}
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Copy password")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.performTextInput("password")
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
SearchAction.MasterPasswordRepromptSubmit(
password = "password",
masterPasswordRepromptData = MasterPasswordRepromptData(
cipherId = "mockId-1",
type = MasterPasswordRepromptData.Type.CopyPassword(
password = "mockPassword-1",
),
),
),
)
}
composeTestRule.assertNoDialogExists()
}
@Test
fun `on send item overflow click should display dialog`() {
val number = 1

View file

@ -330,7 +330,7 @@ class SearchViewModelTest : BaseViewModelTest() {
password = password,
masterPasswordRepromptData = MasterPasswordRepromptData(
cipherId = cipherId,
type = MasterPasswordRepromptData.Type.AUTOFILL,
type = MasterPasswordRepromptData.Type.Autofill,
),
),
)
@ -368,7 +368,7 @@ class SearchViewModelTest : BaseViewModelTest() {
password = password,
masterPasswordRepromptData = MasterPasswordRepromptData(
cipherId = cipherId,
type = MasterPasswordRepromptData.Type.AUTOFILL,
type = MasterPasswordRepromptData.Type.Autofill,
),
),
)
@ -403,7 +403,7 @@ class SearchViewModelTest : BaseViewModelTest() {
password = password,
masterPasswordRepromptData = MasterPasswordRepromptData(
cipherId = cipherId,
type = MasterPasswordRepromptData.Type.AUTOFILL,
type = MasterPasswordRepromptData.Type.Autofill,
),
),
)
@ -447,7 +447,7 @@ class SearchViewModelTest : BaseViewModelTest() {
password = password,
masterPasswordRepromptData = MasterPasswordRepromptData(
cipherId = cipherId,
type = MasterPasswordRepromptData.Type.AUTOFILL_AND_SAVE,
type = MasterPasswordRepromptData.Type.AutofillAndSave,
),
),
)
@ -460,6 +460,59 @@ class SearchViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `MasterPasswordRepromptSubmit for a request Success with a valid password for edit should emit NavigateToEditCipher`() =
runTest {
val cipherId = "cipherId-1234"
val password = "password"
val viewModel = createViewModel()
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Success(isValid = true)
viewModel.eventFlow.test {
viewModel.trySendAction(
SearchAction.MasterPasswordRepromptSubmit(
password = password,
masterPasswordRepromptData = MasterPasswordRepromptData(
cipherId = cipherId,
type = MasterPasswordRepromptData.Type.Edit,
),
),
)
assertEquals(SearchEvent.NavigateToEditCipher(cipherId), awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `MasterPasswordRepromptSubmit for a request Success with a valid password for copy password should call setText on the ClipboardManager`() =
runTest {
val cipherId = "cipherId-1234"
val password = "password"
val viewModel = createViewModel()
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Success(isValid = true)
viewModel.trySendAction(
SearchAction.MasterPasswordRepromptSubmit(
password = password,
masterPasswordRepromptData = MasterPasswordRepromptData(
cipherId = cipherId,
type = MasterPasswordRepromptData.Type.CopyPassword(
password = password,
),
),
),
)
verify(exactly = 1) {
clipboardManager.setText(password)
}
}
@Test
fun `OverflowOptionClick Send EditClick should emit NavigateToEditSend`() = runTest {
val sendId = "sendId"

View file

@ -5,6 +5,9 @@ import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.SemanticsNodeInteractionCollection
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
@ -44,6 +47,35 @@ fun ComposeContentTestRule.assertNoDialogExists() {
.assertDoesNotExist()
}
/**
* Asserts that the master password reprompt dialog is displayed.
*/
fun ComposeContentTestRule.assertMasterPasswordDialogDisplayed() {
this
.onAllNodesWithText(text = "Master password confirmation")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
this
.onAllNodesWithText(
text = "This action is protected, to continue please re-enter your master " +
"password to verify your identity.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
this
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
this
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
this
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
/**
* A helper that asserts that the node does not exist in the scrollable list.
*/