Add VaultItemView tests (#315)

This commit is contained in:
David Perez 2023-12-06 10:24:20 -06:00 committed by Álison Fernandes
parent ca582ce271
commit 2e2fede945
4 changed files with 1325 additions and 14 deletions

View file

@ -1,6 +1,9 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
@ -106,17 +109,23 @@ fun VaultItemScreen(
)
},
floatingActionButton = {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.primaryContainer,
onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.EditClick) }
},
modifier = Modifier.padding(bottom = 16.dp),
AnimatedVisibility(
visible = state.viewState is VaultItemState.ViewState.Content,
enter = scaleIn(),
exit = scaleOut(),
) {
Icon(
painter = painterResource(id = R.drawable.ic_edit),
contentDescription = stringResource(id = R.string.edit_item),
)
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.primaryContainer,
onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.EditClick) }
},
modifier = Modifier.padding(bottom = 16.dp),
) {
Icon(
painter = painterResource(id = R.drawable.ic_edit),
contentDescription = stringResource(id = R.string.edit_item),
)
}
}
},
) { innerPadding ->

View file

@ -0,0 +1,54 @@
package com.x8bit.bitwarden.ui.util
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import org.junit.jupiter.api.assertThrows
/**
* A [SemanticsMatcher] used to find progressbar nodes.
*/
val isProgressBar: SemanticsMatcher
get() = SemanticsMatcher("ProgressBar") {
it.config
.getOrNull(SemanticsProperties.ProgressBarRangeInfo)
?.let { true }
?: false
}
/**
* A helper that asserts that the node does not exist in the scrollable list.
*/
fun ComposeContentTestRule.assertScrollableNodeDoesNotExist(text: String) {
val scrollableNodeInteraction = onNode(hasScrollToNodeAction())
assertThrows<AssertionError> {
// throws since it cannot find the node.
scrollableNodeInteraction.performScrollToNode(hasText(text))
}
}
/**
* A helper used to scroll to and get the matching node in a scrollable list. This is intended to
* be used with lazy lists that would otherwise fail when calling [performScrollToNode].
*/
fun ComposeContentTestRule.onNodeWithTextAfterScroll(text: String): SemanticsNodeInteraction {
onNode(hasScrollToNodeAction()).performScrollToNode(hasText(text))
return onNodeWithText(text)
}
/**
* A helper used to scroll to and get a thr first matching node in a scrollable list. This is
* intended to be used with lazy lists that would otherwise fail when calling [performScrollToNode].
*/
fun ComposeContentTestRule.onFirstNodeWithTextAfterScroll(text: String): SemanticsNodeInteraction {
onNode(hasScrollToNodeAction()).performScrollToNode(hasText(text))
return onAllNodesWithText(text).onFirst()
}

View file

@ -1,14 +1,38 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.test.assert
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.isDialog
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.onSiblings
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.core.net.toUri
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.util.assertScrollableNodeDoesNotExist
import com.x8bit.bitwarden.ui.util.isProgressBar
import com.x8bit.bitwarden.ui.util.onFirstNodeWithTextAfterScroll
import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@ -17,6 +41,9 @@ class VaultItemScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToVaultEditItemId: String? = null
private val clipboardManager = mockk<ClipboardManager>()
private val intentHandler = mockk<IntentHandler>()
private val mutableEventFlow = MutableSharedFlow<VaultItemEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
@ -33,6 +60,8 @@ class VaultItemScreenTest : BaseComposeTest() {
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToVaultEditItem = { onNavigateToVaultEditItemId = it },
clipboardManager = clipboardManager,
intentHandler = intentHandler,
)
}
}
@ -45,13 +74,609 @@ class VaultItemScreenTest : BaseComposeTest() {
}
@Test
fun `clicking close button should send CloseClick action`() {
fun `on close click should send CloseClick`() {
composeTestRule.onNodeWithContentDescription(label = "Close").performClick()
verify {
viewModel.trySendAction(VaultItemAction.CloseClick)
}
}
@Test
fun `CopyToClipboard event should invoke setText`() {
val textString = "text"
val text = textString.asText()
every { clipboardManager.setText(textString.toAnnotatedString()) } just runs
mutableEventFlow.tryEmit(VaultItemEvent.CopyToClipboard(text))
verify(exactly = 1) {
clipboardManager.setText(textString.toAnnotatedString())
}
}
@Test
fun `NavigateBack event should invoke onNavigateBack`() {
mutableEventFlow.tryEmit(VaultItemEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToUri event should invoke launchUri`() {
val uriString = "http://www.example.com"
val uri = uriString.toUri()
every { intentHandler.launchUri(uri) } just runs
mutableEventFlow.tryEmit(VaultItemEvent.NavigateToUri(uriString))
verify(exactly = 1) {
intentHandler.launchUri(uri)
}
}
@Test
fun `basic dialog should be displayed according to state`() {
val message = "message"
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText(message).assertDoesNotExist()
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.Generic("message".asText()))
}
composeTestRule
.onNodeWithText(message)
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
}
@Test
fun `Ok click on generic dialog should emit DismissDialogClick`() {
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.Generic("message".asText()))
}
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.DismissDialogClick)
}
}
@Test
fun `loading dialog should be displayed according to state`() {
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText("Loading").assertDoesNotExist()
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.Loading)
}
composeTestRule
.onNodeWithText("Loading")
.assertIsDisplayed()
.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)
}
composeTestRule
.onNodeWithText("Master password confirmation")
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
}
@Test
fun `Ok click on master password dialog should emit DismissDialogClick`() {
val enteredPassword = "pass1234"
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog)
}
composeTestRule.onNodeWithText("Master password").performTextInput(enteredPassword)
composeTestRule
.onAllNodesWithText("Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.MasterPasswordSubmit(enteredPassword))
}
}
@Test
fun `in login state, on username copy click should send CopyUsernameClick`() {
val username = "username1234"
mutableStateFlow.update {
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(username = username))
}
composeTestRule
.onNodeWithTextAfterScroll(username)
.onSiblings()
.filterToOne(hasContentDescription("Copy username"))
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.Login.CopyUsernameClick)
}
}
@Test
fun `in login state, on breach check click should send CheckForBreachClick`() {
val passwordData = VaultItemState.ViewState.Content.PasswordData(
password = "12345",
isVisible = true,
)
mutableStateFlow.update {
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(passwordData = passwordData))
}
composeTestRule
.onNodeWithTextAfterScroll(passwordData.password)
.onSiblings()
.filterToOne(hasContentDescription("Check known data breaches for this password"))
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.Login.CheckForBreachClick)
}
}
@Test
fun `in login state, on show password click should send CopyPasswordClick`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
composeTestRule
.onNodeWithTextAfterScroll("Password")
.onChildren()
.filterToOne(hasContentDescription("Show"))
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(VaultItemAction.Login.PasswordVisibilityClicked(true))
}
}
@Test
fun `in login state, on copy password click should send CopyPasswordClick`() {
val passwordData = VaultItemState.ViewState.Content.PasswordData(
password = "12345",
isVisible = true,
)
mutableStateFlow.update {
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(passwordData = passwordData))
}
composeTestRule
.onNodeWithTextAfterScroll(passwordData.password)
.onSiblings()
.filterToOne(hasContentDescription("Copy password"))
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.Login.CopyPasswordClick)
}
}
@Test
fun `in login state, launch uri button should be displayed according to state`() {
val uriData = VaultItemState.ViewState.Content.UriData(
uri = "www.example.com",
isCopyable = true,
isLaunchable = true,
)
mutableStateFlow.update {
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData)))
}
composeTestRule
.onNodeWithTextAfterScroll(uriData.uri)
.onSiblings()
.filterToOne(hasContentDescription("Launch"))
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
uris = listOf(uriData.copy(isLaunchable = false)),
),
)
}
composeTestRule
.onNodeWithTextAfterScroll(uriData.uri)
.onSiblings()
.filterToOne(hasContentDescription("Launch"))
.assertDoesNotExist()
}
@Test
fun `in login state, copy uri button should be displayed according to state`() {
val uriData = VaultItemState.ViewState.Content.UriData(
uri = "www.example.com",
isCopyable = true,
isLaunchable = true,
)
mutableStateFlow.update {
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData)))
}
composeTestRule
.onNodeWithTextAfterScroll(uriData.uri)
.onSiblings()
.filterToOne(hasContentDescription("Copy"))
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
uris = listOf(uriData.copy(isCopyable = false)),
),
)
}
composeTestRule
.onNodeWithTextAfterScroll(uriData.uri)
.onSiblings()
.filterToOne(hasContentDescription("Copy"))
.assertDoesNotExist()
}
@Test
fun `in login state, on launch URI click should send LaunchClick`() {
val uriData = VaultItemState.ViewState.Content.UriData(
uri = "www.example.com",
isCopyable = true,
isLaunchable = true,
)
mutableStateFlow.update {
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData)))
}
composeTestRule
.onNodeWithTextAfterScroll(uriData.uri)
.onSiblings()
.filterToOne(hasContentDescription("Launch"))
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.Login.LaunchClick(uriData.uri))
}
}
@Test
fun `in login state, on copy URI click should send CopyUriClick`() {
val uriData = VaultItemState.ViewState.Content.UriData(
uri = "www.example.com",
isCopyable = true,
isLaunchable = true,
)
mutableStateFlow.update {
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData)))
}
composeTestRule
.onNodeWithTextAfterScroll(uriData.uri)
.onSiblings()
.filterToOne(hasContentDescription("Copy"))
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.Login.CopyUriClick(uriData.uri))
}
}
@Test
fun `in login state, on show hidden field click should send HiddenFieldVisibilityClicked`() {
val textField = VaultItemState.ViewState.Content.Custom.HiddenField(
name = "hidden",
value = "hidden password",
isCopyable = true,
isVisible = false,
)
mutableStateFlow.update {
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(textField)))
}
composeTestRule
.onNodeWithTextAfterScroll(textField.name)
.onChildren()
.filterToOne(hasContentDescription("Show"))
.performClick()
verify {
viewModel.trySendAction(
VaultItemAction.Login.HiddenFieldVisibilityClicked(
field = textField,
isVisible = true,
),
)
}
}
@Test
fun `in login state, copy hidden field button should be displayed according to state`() {
val hiddenField = VaultItemState.ViewState.Content.Custom.HiddenField(
name = "hidden",
value = "hidden password",
isCopyable = true,
isVisible = false,
)
mutableStateFlow.update {
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(hiddenField)))
}
composeTestRule
.onNodeWithTextAfterScroll(hiddenField.name)
.onSiblings()
.filterToOne(hasContentDescription("Copy"))
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
customFields = listOf(hiddenField.copy(isCopyable = false)),
),
)
}
composeTestRule
.onNodeWithTextAfterScroll(hiddenField.name)
.onSiblings()
.filterToOne(hasContentDescription("Copy"))
.assertDoesNotExist()
}
@Test
fun `in login state, on copy hidden field click should send CopyCustomHiddenFieldClick`() {
val hiddenField = VaultItemState.ViewState.Content.Custom.HiddenField(
name = "hidden",
value = "hidden password",
isCopyable = true,
isVisible = false,
)
mutableStateFlow.update {
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(hiddenField)))
}
composeTestRule
.onNodeWithTextAfterScroll(hiddenField.name)
.onSiblings()
.filterToOne(hasContentDescription("Copy"))
.performClick()
verify {
viewModel.trySendAction(
VaultItemAction.Login.CopyCustomHiddenFieldClick(hiddenField.value),
)
}
}
@Test
fun `in login state, on copy text field click should send CopyCustomTextFieldClick`() {
val textField = VaultItemState.ViewState.Content.Custom.TextField(
name = "text",
value = "value",
isCopyable = true,
)
mutableStateFlow.update {
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(textField)))
}
composeTestRule
.onNodeWithTextAfterScroll(textField.name)
.onSiblings()
.filterToOne(hasContentDescription("Copy"))
.performClick()
verify {
viewModel.trySendAction(
VaultItemAction.Login.CopyCustomTextFieldClick(textField.value),
)
}
}
@Test
fun `in login state, text field copy button should be displayed according to state`() {
val textField = VaultItemState.ViewState.Content.Custom.TextField(
name = "text",
value = "value",
isCopyable = true,
)
mutableStateFlow.update {
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(textField)))
}
composeTestRule
.onNodeWithTextAfterScroll(textField.name)
.onSiblings()
.filterToOne(hasContentDescription("Copy"))
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
customFields = listOf(textField.copy(isCopyable = false)),
),
)
}
composeTestRule
.onNodeWithTextAfterScroll(textField.name)
.onSiblings()
.filterToOne(hasContentDescription("Copy"))
.assertDoesNotExist()
}
@Test
fun `in login state, on password history click should send PasswordHistoryClick`() {
mutableStateFlow.update {
it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(passwordHistoryCount = 5))
}
composeTestRule.onNodeWithTextAfterScroll("5")
composeTestRule.onNodeWithText("5").performClick()
verify {
viewModel.trySendAction(VaultItemAction.Login.PasswordHistoryClick)
}
}
@Test
fun `fab should be displayed according state`() {
mutableStateFlow.update {
it.copy(viewState = VaultItemState.ViewState.Loading)
}
composeTestRule.onNodeWithContentDescription("Edit item").assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = VaultItemState.ViewState.Error("Fail".asText()))
}
composeTestRule.onNodeWithContentDescription("Edit item").assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
}
composeTestRule.onNodeWithContentDescription("Edit item").performClick()
verify(exactly = 1) {
viewModel.trySendAction(VaultItemAction.EditClick)
}
}
@Test
fun `error text and retry should be displayed according to state`() {
val message = "message"
mutableStateFlow.update {
it.copy(viewState = VaultItemState.ViewState.Error(message.asText()))
}
composeTestRule.onNodeWithText(message).assertIsDisplayed()
composeTestRule.onNodeWithText("Try again").assertIsDisplayed()
}
@Test
fun `progressbar should be displayed according to state`() {
mutableStateFlow.update {
it.copy(viewState = VaultItemState.ViewState.Loading)
}
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = VaultItemState.ViewState.Error("Fail".asText()))
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
}
@Test
fun `in login state, username should be displayed according to state`() {
val username = "the username"
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(username).assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(username = null))
}
composeTestRule.assertScrollableNodeDoesNotExist(username)
}
@Test
fun `in login state, uris should be displayed according to state`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll("URIs").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("URI").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("www.example.com").assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(uris = emptyList()))
}
composeTestRule.assertScrollableNodeDoesNotExist("URIs")
composeTestRule.assertScrollableNodeDoesNotExist("URI")
composeTestRule.assertScrollableNodeDoesNotExist("www.example.com")
}
@Test
fun `in login state, notes should be displayed according to state`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
composeTestRule.onFirstNodeWithTextAfterScroll("Notes").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("Lots of notes").assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(notes = null))
}
composeTestRule.assertScrollableNodeDoesNotExist("Notes")
composeTestRule.assertScrollableNodeDoesNotExist("Lots of notes")
}
@Test
fun `in login state, custom views should be displayed according to state`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll("Custom fields").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("text").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("value").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("hidden").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("boolean").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("linked username").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("linked password").assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(customFields = emptyList()))
}
composeTestRule.assertScrollableNodeDoesNotExist("Custom fields")
composeTestRule.assertScrollableNodeDoesNotExist("text")
composeTestRule.assertScrollableNodeDoesNotExist("value")
composeTestRule.assertScrollableNodeDoesNotExist("hidden")
composeTestRule.assertScrollableNodeDoesNotExist("boolean")
composeTestRule.assertScrollableNodeDoesNotExist("linked username")
composeTestRule.assertScrollableNodeDoesNotExist("linked password")
}
@Test
fun `in login state, password updated should be displayed according to state`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll("Password updated: ").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("4/14/83 3:56 PM").assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(passwordRevisionDate = null))
}
composeTestRule.assertScrollableNodeDoesNotExist("Password updated: ")
composeTestRule.assertScrollableNodeDoesNotExist("4/14/83 3:56 PM")
}
@Test
fun `in login state, password history should be displayed according to state`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll("Password history: ").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("1").assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(passwordHistoryCount = null))
}
composeTestRule.assertScrollableNodeDoesNotExist("Password history: ")
composeTestRule.assertScrollableNodeDoesNotExist("1")
}
}
private const val VAULT_ITEM_ID = "vault_item_id"
@ -61,3 +686,68 @@ private val DEFAULT_STATE: VaultItemState = VaultItemState(
viewState = VaultItemState.ViewState.Loading,
dialog = null,
)
private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login =
VaultItemState.ViewState.Content.Login(
name = "login cipher",
lastUpdated = "12/31/69 06:16 PM",
passwordHistoryCount = 1,
notes = "Lots of notes",
isPremiumUser = true,
customFields = listOf(
VaultItemState.ViewState.Content.Custom.TextField(
name = "text",
value = "value",
isCopyable = true,
),
VaultItemState.ViewState.Content.Custom.HiddenField(
name = "hidden",
value = "hidden password",
isCopyable = true,
isVisible = false,
),
VaultItemState.ViewState.Content.Custom.BooleanField(
name = "boolean",
value = true,
),
VaultItemState.ViewState.Content.Custom.LinkedField(
name = "linked username",
id = 100U,
),
VaultItemState.ViewState.Content.Custom.LinkedField(
name = "linked password",
id = 101U,
),
),
requiresReprompt = true,
username = "the username",
passwordData = VaultItemState.ViewState.Content.PasswordData(
password = "the password",
isVisible = false,
),
uris = listOf(
VaultItemState.ViewState.Content.UriData(
uri = "www.example.com",
isCopyable = true,
isLaunchable = true,
),
),
passwordRevisionDate = "4/14/83 3:56 PM",
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
)
private val EMPTY_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login =
VaultItemState.ViewState.Content.Login(
name = "login cipher",
lastUpdated = "12/31/69 06:16 PM",
passwordHistoryCount = null,
notes = null,
isPremiumUser = true,
customFields = emptyList(),
requiresReprompt = true,
username = null,
passwordData = null,
uris = emptyList(),
passwordRevisionDate = null,
totp = null,
)

View file

@ -3,16 +3,31 @@ package com.x8bit.bitwarden.ui.vault.feature.item
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.CipherView
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.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.item.util.DEFAULT_EMPTY_LOGIN_VIEW_STATE
import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class VaultItemViewModelTest : BaseViewModelTest() {
@ -27,6 +42,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
every { getVaultItemStateFlow(VAULT_ITEM_ID) } returns mutableVaultItemFlow
}
@BeforeEach
fun setup() {
mockkStatic(CIPHER_VIEW_EXTENSIONS_PATH)
}
@AfterEach
fun tearDown() {
unmockkStatic(CIPHER_VIEW_EXTENSIONS_PATH)
}
@Test
fun `initial state should be correct when not set`() {
val viewModel = createViewModel(state = null)
@ -45,16 +70,495 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
@Test
fun `on BackClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
fun `on CloseClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel(state = DEFAULT_STATE)
viewModel.eventFlow.test {
viewModel.trySendAction(VaultItemAction.CloseClick)
assertEquals(VaultItemEvent.NavigateBack, awaitItem())
}
}
@Test
fun `on DismissDialogClick should clear the dialog state`() = runTest {
val initialState = DEFAULT_STATE.copy(dialog = VaultItemState.DialogState.Loading)
val viewModel = createViewModel(state = initialState)
assertEquals(initialState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.DismissDialogClick)
assertEquals(initialState.copy(dialog = null), viewModel.stateFlow.value)
}
@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.EditClick)
expectNoEvents()
}
assertEquals(initialState, viewModel.stateFlow.value)
}
@Test
fun `on EditClick should prompt for master password when required`() = runTest {
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
val viewModel = createViewModel(state = loginState)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.EditClick)
assertEquals(
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Test
fun `on EditClick should navigate password is not required`() = runTest {
val loginViewState = DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns loginViewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
val viewModel = createViewModel(state = loginState)
viewModel.eventFlow.test {
viewModel.trySendAction(VaultItemAction.EditClick)
assertEquals(VaultItemEvent.NavigateToEdit(VAULT_ITEM_ID), awaitItem())
}
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Test
fun `on MasterPasswordSubmit should verify the password`() = runTest {
val loginViewState = DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns loginViewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
val viewModel = createViewModel(state = loginState)
viewModel.stateFlow.test {
assertEquals(loginState, awaitItem())
viewModel.trySendAction(VaultItemAction.MasterPasswordSubmit("password"))
assertEquals(loginState.copy(dialog = VaultItemState.DialogState.Loading), awaitItem())
assertEquals(
loginState.copy(viewState = loginViewState.copy(requiresReprompt = false)),
awaitItem(),
)
}
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Test
fun `on RefreshClick should sync`() = runTest {
every { vaultRepo.sync() } just runs
val viewModel = createViewModel(state = DEFAULT_STATE)
viewModel.trySendAction(VaultItemAction.RefreshClick)
verify(exactly = 1) {
vaultRepo.sync()
}
}
@Nested
inner class LoginActions {
private lateinit var viewModel: VaultItemViewModel
@BeforeEach
fun setup() {
viewModel = createViewModel(
state = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE),
)
}
@Test
fun `on CheckForBreachClick should process a password`() = runTest {
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
val breachCount = 5
coEvery {
authRepo.getPasswordBreachCount(password = DEFAULT_LOGIN_PASSWORD)
} returns BreachCountResult.Success(breachCount = breachCount)
viewModel.stateFlow.test {
assertEquals(loginState, awaitItem())
viewModel.trySendAction(VaultItemAction.Login.CheckForBreachClick)
assertEquals(
loginState.copy(dialog = VaultItemState.DialogState.Loading),
awaitItem(),
)
assertEquals(
loginState.copy(
dialog = VaultItemState.DialogState.Generic(
message = R.string.password_exposed.asText(breachCount),
),
),
awaitItem(),
)
}
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
coVerify(exactly = 1) {
authRepo.getPasswordBreachCount(password = DEFAULT_LOGIN_PASSWORD)
}
}
@Test
fun `on CopyPasswordClick should show password dialog when re-prompt is required`() =
runTest {
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.Login.CopyPasswordClick)
assertEquals(
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Test
fun `on CopyPasswordClick should emit CopyToClipboard when re-prompt is not required`() =
runTest {
val mockCipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true)
} returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
viewModel.eventFlow.test {
viewModel.trySendAction(VaultItemAction.Login.CopyPasswordClick)
assertEquals(
VaultItemEvent.CopyToClipboard(DEFAULT_LOGIN_PASSWORD.asText()),
awaitItem(),
)
}
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Suppress("MaxLineLength")
@Test
fun `on CopyCustomHiddenFieldClick should show password dialog when re-prompt is required`() =
runTest {
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.Login.CopyCustomHiddenFieldClick("field"))
assertEquals(
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Suppress("MaxLineLength")
@Test
fun `on CopyCustomHiddenFieldClick should emit CopyToClipboard when re-prompt is not required`() =
runTest {
val field = "field"
val mockCipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true)
} returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
viewModel.eventFlow.test {
viewModel.trySendAction(VaultItemAction.Login.CopyCustomHiddenFieldClick(field))
assertEquals(
VaultItemEvent.CopyToClipboard(field.asText()),
awaitItem(),
)
}
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Test
fun `on CopyCustomTextFieldClick should emit CopyToClipboard`() = runTest {
val field = "field"
viewModel.eventFlow.test {
viewModel.trySendAction(VaultItemAction.Login.CopyCustomTextFieldClick(field))
assertEquals(VaultItemEvent.CopyToClipboard(field.asText()), awaitItem())
}
}
@Test
fun `on CopyUriClick should emit CopyToClipboard`() = runTest {
val uri = "uri"
viewModel.eventFlow.test {
viewModel.trySendAction(VaultItemAction.Login.CopyUriClick(uri))
assertEquals(VaultItemEvent.CopyToClipboard(uri.asText()), awaitItem())
}
}
@Test
fun `on CopyUsernameClick should show password dialog when re-prompt is required`() =
runTest {
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.Login.CopyUsernameClick)
assertEquals(
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Test
fun `on CopyUsernameClick should emit CopyToClipboard when re-prompt is not required`() =
runTest {
val mockCipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true)
} returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
viewModel.eventFlow.test {
viewModel.trySendAction(VaultItemAction.Login.CopyUsernameClick)
assertEquals(
VaultItemEvent.CopyToClipboard(DEFAULT_LOGIN_USERNAME.asText()),
awaitItem(),
)
}
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Test
fun `on LaunchClick should emit NavigateToUri`() = runTest {
val uri = "uri"
viewModel.eventFlow.test {
viewModel.trySendAction(VaultItemAction.Login.LaunchClick(uri))
assertEquals(VaultItemEvent.NavigateToUri(uri), awaitItem())
}
}
@Test
fun `on PasswordHistoryClick should show password dialog when re-prompt is required`() =
runTest {
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.Login.PasswordHistoryClick)
assertEquals(
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Suppress("MaxLineLength")
@Test
fun `on PasswordHistoryClick should emit NavigateToPasswordHistory when re-prompt is not required`() =
runTest {
val mockCipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true)
} returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
viewModel.eventFlow.test {
viewModel.trySendAction(VaultItemAction.Login.PasswordHistoryClick)
assertEquals(
VaultItemEvent.NavigateToPasswordHistory(VAULT_ITEM_ID),
awaitItem(),
)
}
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Suppress("MaxLineLength")
@Test
fun `on PasswordVisibilityClicked should show password dialog when re-prompt is required`() =
runTest {
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.Login.PasswordVisibilityClicked(true))
assertEquals(
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Suppress("MaxLineLength")
@Test
fun `on PasswordVisibilityClicked should update password visibility when re-prompt is not required`() =
runTest {
val loginViewState = DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false)
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns loginViewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.Login.PasswordVisibilityClicked(true))
assertEquals(
loginState.copy(
viewState = loginViewState.copy(
passwordData = loginViewState.passwordData!!.copy(isVisible = true),
),
),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Suppress("MaxLineLength")
@Test
fun `on HiddenFieldVisibilityClicked should show password dialog when re-prompt is required`() =
runTest {
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(
VaultItemAction.Login.HiddenFieldVisibilityClicked(
field = VaultItemState.ViewState.Content.Custom.HiddenField(
name = "hidden",
value = "value",
isCopyable = true,
isVisible = false,
),
isVisible = true,
),
)
assertEquals(
loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Suppress("MaxLineLength")
@Test
fun `on HiddenFieldVisibilityClicked should update hidden field visibility when re-prompt is not required`() =
runTest {
val hiddenField = VaultItemState.ViewState.Content.Custom.HiddenField(
name = "hidden",
value = "value",
isCopyable = true,
isVisible = false,
)
val loginViewState = DEFAULT_EMPTY_LOGIN_VIEW_STATE.copy(
requiresReprompt = false,
customFields = listOf(hiddenField),
)
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns loginViewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(
VaultItemAction.Login.HiddenFieldVisibilityClicked(
field = hiddenField,
isVisible = true,
),
)
assertEquals(
loginState.copy(
viewState = loginViewState.copy(
customFields = listOf(hiddenField.copy(isVisible = true)),
),
),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
}
private fun createViewModel(
state: VaultItemState? = DEFAULT_STATE,
state: VaultItemState?,
vaultItemId: String = VAULT_ITEM_ID,
authRepository: AuthRepository = authRepo,
vaultRepository: VaultRepository = vaultRepo,
@ -68,7 +572,12 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
}
private const val CIPHER_VIEW_EXTENSIONS_PATH: String =
"com.x8bit.bitwarden.ui.vault.feature.item.util.CipherViewExtensionsKt"
private const val VAULT_ITEM_ID = "vault_item_id"
private const val DEFAULT_LOGIN_PASSWORD = "password"
private const val DEFAULT_LOGIN_USERNAME = "username"
private val DEFAULT_STATE: VaultItemState = VaultItemState(
vaultItemId = VAULT_ITEM_ID,
@ -89,3 +598,52 @@ private val DEFAULT_USER_STATE: UserState = UserState(
),
),
)
private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login =
VaultItemState.ViewState.Content.Login(
name = "login cipher",
lastUpdated = "12/31/69 06:16 PM",
passwordHistoryCount = 1,
notes = "Lots of notes",
isPremiumUser = true,
customFields = listOf(
VaultItemState.ViewState.Content.Custom.TextField(
name = "text",
value = "value",
isCopyable = true,
),
VaultItemState.ViewState.Content.Custom.HiddenField(
name = "hidden",
value = "value",
isCopyable = true,
isVisible = false,
),
VaultItemState.ViewState.Content.Custom.BooleanField(
name = "boolean",
value = true,
),
VaultItemState.ViewState.Content.Custom.LinkedField(
name = "linked username",
id = 100U,
),
VaultItemState.ViewState.Content.Custom.LinkedField(
name = "linked password",
id = 101U,
),
),
requiresReprompt = true,
username = DEFAULT_LOGIN_USERNAME,
passwordData = VaultItemState.ViewState.Content.PasswordData(
password = DEFAULT_LOGIN_PASSWORD,
isVisible = false,
),
uris = listOf(
VaultItemState.ViewState.Content.UriData(
uri = "www.example.com",
isCopyable = true,
isLaunchable = true,
),
),
passwordRevisionDate = "12/31/69 06:16 PM",
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
)