BIT-617: Vault Password History (#935)

This commit is contained in:
Ramsey Smith 2024-02-01 09:16:07 -07:00 committed by Álison Fernandes
parent 46bc489f1f
commit 8156e306f5
12 changed files with 427 additions and 74 deletions

View file

@ -22,6 +22,7 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.folders.navigateToFolder
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination
import com.x8bit.bitwarden.ui.tools.feature.generator.generatorModalDestination import com.x8bit.bitwarden.ui.tools.feature.generator.generatorModalDestination
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorPasswordHistoryMode
import com.x8bit.bitwarden.ui.tools.feature.generator.navigateToGeneratorModal import com.x8bit.bitwarden.ui.tools.feature.generator.navigateToGeneratorModal
import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.navigateToPasswordHistory import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.navigateToPasswordHistory
import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.passwordHistoryDestination import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.passwordHistoryDestination
@ -90,7 +91,11 @@ fun NavGraphBuilder.vaultUnlockedGraph(
onNavigateToEditSend = { navController.navigateToAddSend(AddSendType.EditItem(it)) }, onNavigateToEditSend = { navController.navigateToAddSend(AddSendType.EditItem(it)) },
onNavigateToDeleteAccount = { navController.navigateToDeleteAccount() }, onNavigateToDeleteAccount = { navController.navigateToDeleteAccount() },
onNavigateToPendingRequests = { navController.navigateToPendingRequests() }, onNavigateToPendingRequests = { navController.navigateToPendingRequests() },
onNavigateToPasswordHistory = { navController.navigateToPasswordHistory() }, onNavigateToPasswordHistory = {
navController.navigateToPasswordHistory(
passwordHistoryMode = GeneratorPasswordHistoryMode.Default,
)
},
) )
deleteAccountDestination(onNavigateBack = { navController.popBackStack() }) deleteAccountDestination(onNavigateBack = { navController.popBackStack() })
loginApprovalDestination(onNavigateBack = { navController.popBackStack() }) loginApprovalDestination(onNavigateBack = { navController.popBackStack() })
@ -136,6 +141,11 @@ fun NavGraphBuilder.vaultUnlockedGraph(
) )
}, },
onNavigateToAttachments = { navController.navigateToAttachment(it) }, onNavigateToAttachments = { navController.navigateToAttachment(it) },
onNavigateToPasswordHistory = {
navController.navigateToPasswordHistory(
passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = it),
)
},
) )
vaultQrCodeScanDestination( vaultQrCodeScanDestination(
onNavigateToManualCodeEntryScreen = { onNavigateToManualCodeEntryScreen = {

View file

@ -0,0 +1,22 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Represents the different modes the password history screen can be in.
*/
sealed class GeneratorPasswordHistoryMode : Parcelable {
/**
* Represents the main or default password history mode.
*/
@Parcelize
data object Default : GeneratorPasswordHistoryMode()
/**
* Represents the item password history mode.
*/
@Parcelize
data class Item(val itemId: String) : GeneratorPasswordHistoryMode()
}

View file

@ -1,14 +1,45 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorPasswordHistoryMode
private const val DEFAULT_MODE: String = "default"
private const val ITEM_MODE: String = "item"
private const val PASSWORD_HISTORY_PREFIX: String = "password_history"
private const val PASSWORD_HISTORY_MODE: String = "password_history_mode"
private const val PASSWORD_HISTORY_ITEM_ID: String = "password_history_id"
private const val PASSWORD_HISTORY_ROUTE: String =
PASSWORD_HISTORY_PREFIX +
"/{$PASSWORD_HISTORY_MODE}" +
"?$PASSWORD_HISTORY_ITEM_ID={$PASSWORD_HISTORY_ITEM_ID}"
/** /**
* The functions below pertain to entry into the [PasswordHistoryScreen]. * Class to retrieve password history arguments from the [SavedStateHandle].
*/ */
private const val PASSWORD_HISTORY_ROUTE: String = "password_history" @OmitFromCoverage
data class PasswordHistoryArgs(
val passwordHistoryMode: GeneratorPasswordHistoryMode,
) {
constructor(savedStateHandle: SavedStateHandle) : this(
passwordHistoryMode = when (requireNotNull(savedStateHandle[PASSWORD_HISTORY_MODE])) {
DEFAULT_MODE -> GeneratorPasswordHistoryMode.Default
ITEM_MODE -> GeneratorPasswordHistoryMode.Item(
requireNotNull(savedStateHandle[PASSWORD_HISTORY_ITEM_ID]),
)
else -> throw IllegalStateException("Unknown VaultAddEditType.")
},
)
}
/** /**
* Add password history destination to the graph. * Add password history destination to the graph.
@ -17,8 +48,10 @@ fun NavGraphBuilder.passwordHistoryDestination(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
) { ) {
composableWithSlideTransitions( composableWithSlideTransitions(
// TODO: (BIT-617) Allow Password History screen to launch from VaultItemScreen
route = PASSWORD_HISTORY_ROUTE, route = PASSWORD_HISTORY_ROUTE,
arguments = listOf(
navArgument(PASSWORD_HISTORY_MODE) { type = NavType.StringType },
),
) { ) {
PasswordHistoryScreen( PasswordHistoryScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
@ -29,6 +62,25 @@ fun NavGraphBuilder.passwordHistoryDestination(
/** /**
* Navigate to the Password History Screen. * Navigate to the Password History Screen.
*/ */
fun NavController.navigateToPasswordHistory(navOptions: NavOptions? = null) { fun NavController.navigateToPasswordHistory(
navigate(PASSWORD_HISTORY_ROUTE, navOptions) passwordHistoryMode: GeneratorPasswordHistoryMode,
navOptions: NavOptions? = null,
) {
navigate(
route = "$PASSWORD_HISTORY_PREFIX/${passwordHistoryMode.toModeString()}" +
"?$PASSWORD_HISTORY_ITEM_ID=${passwordHistoryMode.toIdOrNull()}",
navOptions = navOptions,
)
} }
private fun GeneratorPasswordHistoryMode.toModeString(): String =
when (this) {
is GeneratorPasswordHistoryMode.Default -> DEFAULT_MODE
is GeneratorPasswordHistoryMode.Item -> ITEM_MODE
}
private fun GeneratorPasswordHistoryMode.toIdOrNull(): String? =
when (this) {
is GeneratorPasswordHistoryMode.Default -> null
is GeneratorPasswordHistoryMode.Item -> itemId
}

View file

@ -82,21 +82,23 @@ fun PasswordHistoryScreen(
{ viewModel.trySendAction(PasswordHistoryAction.CloseClick) } { viewModel.trySendAction(PasswordHistoryAction.CloseClick) }
}, },
actions = { actions = {
BitwardenOverflowActionItem( if (state.menuEnabled) {
menuItemDataList = persistentListOf( BitwardenOverflowActionItem(
OverflowMenuItemData( menuItemDataList = persistentListOf(
testTag = "ClearPasswordList", OverflowMenuItemData(
text = stringResource(id = R.string.clear), testTag = "ClearPasswordList",
onClick = remember(viewModel) { text = stringResource(id = R.string.clear),
{ onClick = remember(viewModel) {
viewModel.trySendAction( {
PasswordHistoryAction.PasswordClearClick, viewModel.trySendAction(
) PasswordHistoryAction.PasswordClearClick,
} )
}, }
},
),
), ),
), )
) }
}, },
) )
}, },

View file

@ -1,16 +1,21 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import android.os.Parcelable import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitwarden.core.CipherView
import com.bitwarden.core.PasswordHistoryView import com.bitwarden.core.PasswordHistoryView
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorPasswordHistoryMode
import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.PasswordHistoryState.GeneratedPassword import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.PasswordHistoryState.GeneratedPassword
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -21,24 +26,45 @@ import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import javax.inject.Inject import javax.inject.Inject
private const val KEY_STATE = "state"
/** /**
* ViewModel responsible for handling user interactions in the PasswordHistoryScreen. * ViewModel responsible for handling user interactions in the PasswordHistoryScreen.
*/ */
@HiltViewModel @HiltViewModel
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
class PasswordHistoryViewModel @Inject constructor( class PasswordHistoryViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val clipboardManager: BitwardenClipboardManager, private val clipboardManager: BitwardenClipboardManager,
private val generatorRepository: GeneratorRepository, private val generatorRepository: GeneratorRepository,
private val vaultRepository: VaultRepository,
) : BaseViewModel<PasswordHistoryState, PasswordHistoryEvent, PasswordHistoryAction>( ) : BaseViewModel<PasswordHistoryState, PasswordHistoryEvent, PasswordHistoryAction>(
initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading), initialState = savedStateHandle[KEY_STATE]
?: run {
PasswordHistoryState(
passwordHistoryMode = PasswordHistoryArgs(savedStateHandle).passwordHistoryMode,
viewState = PasswordHistoryState.ViewState.Loading,
)
},
) { ) {
init { init {
generatorRepository when (val passwordHistoryMode = state.passwordHistoryMode) {
.passwordHistoryStateFlow is GeneratorPasswordHistoryMode.Default -> {
.map { PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive(it) } generatorRepository
.onEach(::sendAction) .passwordHistoryStateFlow
.launchIn(viewModelScope) .map { PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
is GeneratorPasswordHistoryMode.Item -> {
vaultRepository
.getVaultItemStateFlow(passwordHistoryMode.itemId)
.map { PasswordHistoryAction.Internal.CipherDataReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
}
} }
override fun handleAction(action: PasswordHistoryAction) { override fun handleAction(action: PasswordHistoryAction) {
@ -49,6 +75,8 @@ class PasswordHistoryViewModel @Inject constructor(
is PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive -> { is PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive -> {
handleUpdatePasswordHistoryReceive(action) handleUpdatePasswordHistoryReceive(action)
} }
is PasswordHistoryAction.Internal.CipherDataReceive -> handleCipherDataReceive(action)
} }
} }
@ -62,22 +90,7 @@ class PasswordHistoryViewModel @Inject constructor(
PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText()) PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText())
} }
is LocalDataState.Loaded -> { is LocalDataState.Loaded -> state.data.toViewState()
val passwords = state.data.map { passwordHistoryView ->
GeneratedPassword(
password = passwordHistoryView.password,
date = passwordHistoryView.lastUsedDate.toFormattedPattern(
pattern = "MM/dd/yy h:mm a",
),
)
}
if (passwords.isEmpty()) {
PasswordHistoryState.ViewState.Empty
} else {
PasswordHistoryState.ViewState.Content(passwords)
}
}
} }
mutableStateFlow.update { mutableStateFlow.update {
@ -85,6 +98,21 @@ class PasswordHistoryViewModel @Inject constructor(
} }
} }
private fun handleCipherDataReceive(action: PasswordHistoryAction.Internal.CipherDataReceive) {
val newState: PasswordHistoryState.ViewState = when (action.state) {
is DataState.Error -> {
PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText())
}
is DataState.Loaded -> action.state.data?.passwordHistory.toViewState()
is DataState.Loading -> PasswordHistoryState.ViewState.Loading
is DataState.NoNetwork -> {
PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText())
}
is DataState.Pending -> action.state.data?.passwordHistory.toViewState()
}
mutableStateFlow.update { it.copy(viewState = newState) }
}
private fun handleCloseClick() { private fun handleCloseClick() {
sendEvent( sendEvent(
event = PasswordHistoryEvent.NavigateBack, event = PasswordHistoryEvent.NavigateBack,
@ -100,18 +128,41 @@ class PasswordHistoryViewModel @Inject constructor(
private fun handleCopyClick(password: GeneratedPassword) { private fun handleCopyClick(password: GeneratedPassword) {
clipboardManager.setText(text = password.password) clipboardManager.setText(text = password.password)
} }
private fun List<PasswordHistoryView>?.toViewState(): PasswordHistoryState.ViewState {
val passwords = this?.map { passwordHistoryView ->
GeneratedPassword(
password = passwordHistoryView.password,
date = passwordHistoryView.lastUsedDate.toFormattedPattern(
pattern = "MM/dd/yy h:mm a",
),
)
}
return if (passwords?.isNotEmpty() == true) {
PasswordHistoryState.ViewState.Content(passwords)
} else {
PasswordHistoryState.ViewState.Empty
}
}
} }
/** /**
* Represents the possible states for the password history screen. * Represents the possible states for the password history screen.
* *
* @property passwordHistoryMode Indicates whether tje VM is in default or item mode.
* @property viewState The current view state of the password history screen. * @property viewState The current view state of the password history screen.
*/ */
@Parcelize @Parcelize
data class PasswordHistoryState( data class PasswordHistoryState(
val passwordHistoryMode: GeneratorPasswordHistoryMode,
val viewState: ViewState, val viewState: ViewState,
) : Parcelable { ) : Parcelable {
/**
* Helper that represents if the menu is enabled.
*/
val menuEnabled: Boolean
get() = passwordHistoryMode is GeneratorPasswordHistoryMode.Default
/** /**
* Represents the specific view states for the password history screen. * Represents the specific view states for the password history screen.
*/ */
@ -211,5 +262,12 @@ sealed class PasswordHistoryAction {
data class UpdatePasswordHistoryReceive( data class UpdatePasswordHistoryReceive(
val state: LocalDataState<List<PasswordHistoryView>>, val state: LocalDataState<List<PasswordHistoryView>>,
) : Internal() ) : Internal()
/**
* Indicates cipher data is received.
*/
data class CipherDataReceive(
val state: DataState<CipherView?>,
) : Internal()
} }
} }

View file

@ -31,6 +31,7 @@ fun NavGraphBuilder.vaultItemDestination(
onNavigateToVaultEditItem: (vaultItemId: String, isClone: Boolean) -> Unit, onNavigateToVaultEditItem: (vaultItemId: String, isClone: Boolean) -> Unit,
onNavigateToMoveToOrganization: (vaultItemId: String, showOnlyCollections: Boolean) -> Unit, onNavigateToMoveToOrganization: (vaultItemId: String, showOnlyCollections: Boolean) -> Unit,
onNavigateToAttachments: (vaultItemId: String) -> Unit, onNavigateToAttachments: (vaultItemId: String) -> Unit,
onNavigateToPasswordHistory: (vaultItemId: String) -> Unit,
) { ) {
composableWithSlideTransitions( composableWithSlideTransitions(
route = VAULT_ITEM_ROUTE, route = VAULT_ITEM_ROUTE,
@ -43,6 +44,7 @@ fun NavGraphBuilder.vaultItemDestination(
onNavigateToVaultAddEditItem = onNavigateToVaultEditItem, onNavigateToVaultAddEditItem = onNavigateToVaultEditItem,
onNavigateToMoveToOrganization = onNavigateToMoveToOrganization, onNavigateToMoveToOrganization = onNavigateToMoveToOrganization,
onNavigateToAttachments = onNavigateToAttachments, onNavigateToAttachments = onNavigateToAttachments,
onNavigateToPasswordHistory = onNavigateToPasswordHistory,
) )
} }
} }

View file

@ -63,6 +63,7 @@ fun VaultItemScreen(
onNavigateToVaultAddEditItem: (vaultItemId: String, isClone: Boolean) -> Unit, onNavigateToVaultAddEditItem: (vaultItemId: String, isClone: Boolean) -> Unit,
onNavigateToMoveToOrganization: (vaultItemId: String, showOnlyCollections: Boolean) -> Unit, onNavigateToMoveToOrganization: (vaultItemId: String, showOnlyCollections: Boolean) -> Unit,
onNavigateToAttachments: (vaultItemId: String) -> Unit, onNavigateToAttachments: (vaultItemId: String) -> Unit,
onNavigateToPasswordHistory: (vaultItemId: String) -> Unit,
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
@ -92,8 +93,7 @@ fun VaultItemScreen(
} }
is VaultItemEvent.NavigateToPasswordHistory -> { is VaultItemEvent.NavigateToPasswordHistory -> {
// TODO Implement password history in BIT-617 onNavigateToPasswordHistory(event.itemId)
Toast.makeText(context, "Not yet implemented.", Toast.LENGTH_SHORT).show()
} }
is VaultItemEvent.NavigateToUri -> intentManager.launchUri(event.uri.toUri()) is VaultItemEvent.NavigateToUri -> intentManager.launchUri(event.uri.toUri())

View file

@ -9,6 +9,7 @@ import com.bitwarden.core.FieldView
import com.bitwarden.core.IdentityView import com.bitwarden.core.IdentityView
import com.bitwarden.core.LoginUriView import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView import com.bitwarden.core.LoginView
import com.bitwarden.core.PasswordHistoryView
import com.bitwarden.core.SecureNoteType import com.bitwarden.core.SecureNoteType
import com.bitwarden.core.SecureNoteView import com.bitwarden.core.SecureNoteView
import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank
@ -33,7 +34,7 @@ fun VaultAddEditState.ViewState.Content.toCipherView(): CipherView =
localData = common.originalCipher?.localData, localData = common.originalCipher?.localData,
attachments = common.originalCipher?.attachments, attachments = common.originalCipher?.attachments,
organizationUseTotp = common.originalCipher?.organizationUseTotp ?: false, organizationUseTotp = common.originalCipher?.organizationUseTotp ?: false,
passwordHistory = common.originalCipher?.passwordHistory, passwordHistory = toPasswordHistory(),
creationDate = common.originalCipher?.creationDate ?: Instant.now(), creationDate = common.originalCipher?.creationDate ?: Instant.now(),
deletedDate = common.originalCipher?.deletedDate, deletedDate = common.originalCipher?.deletedDate,
revisionDate = common.originalCipher?.revisionDate ?: Instant.now(), revisionDate = common.originalCipher?.revisionDate ?: Instant.now(),
@ -114,6 +115,26 @@ private fun VaultAddEditState.ViewState.Content.ItemType.toIdentityView(): Ident
) )
} }
@Suppress("MagicNumber")
private fun VaultAddEditState.ViewState.Content.toPasswordHistory(): List<PasswordHistoryView>? {
val oldPassword = common.originalCipher?.login?.password
return if (oldPassword != null &&
oldPassword != (type as? VaultAddEditState.ViewState.Content.ItemType.Login)?.password
) {
listOf(
PasswordHistoryView(
password = oldPassword,
lastUsedDate = Instant.now(),
),
)
.plus(common.originalCipher?.passwordHistory.orEmpty())
.take(5)
} else {
common.originalCipher?.passwordHistory
}
}
private fun VaultAddEditState.ViewState.Content.ItemType.toLoginView( private fun VaultAddEditState.ViewState.Content.ItemType.toLoginView(
common: VaultAddEditState.ViewState.Content.Common, common: VaultAddEditState.ViewState.Content.Common,
): LoginView? = ): LoginView? =

View file

@ -1,16 +1,23 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isPopup
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorPasswordHistoryMode
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
@ -21,7 +28,10 @@ class PasswordHistoryScreenTest : BaseComposeTest() {
private val mutableEventFlow = bufferedMutableSharedFlow<PasswordHistoryEvent>() private val mutableEventFlow = bufferedMutableSharedFlow<PasswordHistoryEvent>()
private val mutableStateFlow = MutableStateFlow( private val mutableStateFlow = MutableStateFlow(
PasswordHistoryState(PasswordHistoryState.ViewState.Loading), PasswordHistoryState(
passwordHistoryMode = GeneratorPasswordHistoryMode.Default,
viewState = PasswordHistoryState.ViewState.Loading,
),
) )
private val viewModel = mockk<PasswordHistoryViewModel>(relaxed = true) { private val viewModel = mockk<PasswordHistoryViewModel>(relaxed = true) {
@ -41,16 +51,22 @@ class PasswordHistoryScreenTest : BaseComposeTest() {
@Test @Test
fun `Empty state should display no passwords message`() { fun `Empty state should display no passwords message`() {
updateState(PasswordHistoryState(PasswordHistoryState.ViewState.Empty)) mutableStateFlow.update {
it.copy(
viewState = PasswordHistoryState.ViewState.Empty,
)
}
composeTestRule.onNodeWithText("No passwords to list.").assertIsDisplayed() composeTestRule.onNodeWithText("No passwords to list.").assertIsDisplayed()
} }
@Test @Test
fun `Error state should display error message`() { fun `Error state should display error message`() {
val errorMessage = "Error occurred" val errorMessage = "Error occurred"
updateState( mutableStateFlow.update {
PasswordHistoryState(PasswordHistoryState.ViewState.Error(errorMessage.asText())), it.copy(
) viewState = PasswordHistoryState.ViewState.Error(errorMessage.asText()),
)
}
composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed() composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed()
} }
@ -74,13 +90,13 @@ class PasswordHistoryScreenTest : BaseComposeTest() {
@Test @Test
fun `clicking the Copy button should send PasswordCopyClick action`() { fun `clicking the Copy button should send PasswordCopyClick action`() {
val password = PasswordHistoryState.GeneratedPassword(password = "Password", date = "Date") val password = PasswordHistoryState.GeneratedPassword(password = "Password", date = "Date")
updateState( mutableStateFlow.update {
PasswordHistoryState( it.copy(
PasswordHistoryState.ViewState.Content( viewState = PasswordHistoryState.ViewState.Content(
passwords = listOf(password), passwords = listOf(password),
), ),
), )
) }
composeTestRule.onNodeWithText(password.password).assertIsDisplayed() composeTestRule.onNodeWithText(password.password).assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Copy").performClick() composeTestRule.onNodeWithContentDescription("Copy").performClick()
@ -109,18 +125,44 @@ class PasswordHistoryScreenTest : BaseComposeTest() {
} }
} }
@Test
fun `Clear button should depend on state`() {
composeTestRule
.onNodeWithContentDescription(label = "More")
.performClick()
composeTestRule
.onAllNodesWithText("Clear")
.filterToOne(hasAnyAncestor(isPopup()))
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(
passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = "mockId-1"),
)
}
composeTestRule
.onNodeWithContentDescription(label = "More")
.assertIsNotDisplayed()
composeTestRule
.onAllNodesWithText("Clear")
.filterToOne(hasAnyAncestor(isPopup()))
.assertIsNotDisplayed()
}
@Test @Test
fun `Content state should display list of passwords`() { fun `Content state should display list of passwords`() {
val passwords = val passwords =
listOf(PasswordHistoryState.GeneratedPassword(password = "Password1", date = "Date1")) listOf(PasswordHistoryState.GeneratedPassword(password = "Password1", date = "Date1"))
updateState( mutableStateFlow.update {
PasswordHistoryState( it.copy(
PasswordHistoryState.ViewState.Content( viewState = PasswordHistoryState.ViewState.Content(
passwords = passwords, passwords = passwords,
), ),
), )
) }
composeTestRule.onNodeWithText("Password1").assertIsDisplayed() composeTestRule.onNodeWithText("Password1").assertIsDisplayed()
} }

View file

@ -1,19 +1,26 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test import app.cash.turbine.test
import com.bitwarden.core.CipherView
import com.bitwarden.core.PasswordHistoryView import com.bitwarden.core.PasswordHistoryView
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorPasswordHistoryMode
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs import io.mockk.runs
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
@ -22,10 +29,14 @@ import java.time.Instant
class PasswordHistoryViewModelTest : BaseViewModelTest() { class PasswordHistoryViewModelTest : BaseViewModelTest() {
private val initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading) private val initialState = createPasswordHistoryState()
private val clipboardManager: BitwardenClipboardManager = mockk() private val clipboardManager: BitwardenClipboardManager = mockk()
private val fakeGeneratorRepository = FakeGeneratorRepository() private val fakeGeneratorRepository = FakeGeneratorRepository()
private val mutableVaultItemFlow = MutableStateFlow<DataState<CipherView?>>(DataState.Loading)
private val fakeVaultRepository: VaultRepository = mockk {
every { getVaultItemStateFlow("mockId-1") } returns mutableVaultItemFlow
}
@Test @Test
fun `initial state should be correct`() = runTest { fun `initial state should be correct`() = runTest {
@ -41,7 +52,7 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.stateFlow.test { viewModel.stateFlow.test {
val expectedState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading) val expectedState = createPasswordHistoryState()
val actualState = awaitItem() val actualState = awaitItem()
assertEquals(expectedState, actualState) assertEquals(expectedState, actualState)
} }
@ -55,8 +66,10 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.stateFlow.test { viewModel.stateFlow.test {
val expectedState = PasswordHistoryState( val expectedState = createPasswordHistoryState(
PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText()), viewState = PasswordHistoryState.ViewState.Error(
message = R.string.an_error_has_occurred.asText(),
),
) )
val actualState = awaitItem() val actualState = awaitItem()
assertEquals(expectedState, actualState) assertEquals(expectedState, actualState)
@ -69,7 +82,92 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.stateFlow.test { viewModel.stateFlow.test {
val expectedState = PasswordHistoryState(PasswordHistoryState.ViewState.Empty) val expectedState = createPasswordHistoryState(
viewState = PasswordHistoryState.ViewState.Empty,
)
val actualState = awaitItem()
assertEquals(expectedState, actualState)
}
}
@Test
fun `when VaultRepository emits Loading state the state updates correctly`() = runTest {
mutableVaultItemFlow.value = DataState.Loading
val viewModel = createViewModel(
initialState = createPasswordHistoryState(
passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = "mockId-1"),
),
)
viewModel.stateFlow.test {
val expectedState = createPasswordHistoryState(
passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = "mockId-1"),
)
val actualState = awaitItem()
assertEquals(expectedState, actualState)
}
}
@Test
fun `when VaultRepository emits Error state the state updates correctly`() = runTest {
mutableVaultItemFlow.value = DataState.Error(error = IllegalStateException())
val viewModel = createViewModel(
initialState = createPasswordHistoryState(
passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = "mockId-1"),
),
)
viewModel.stateFlow.test {
val expectedState = createPasswordHistoryState(
viewState = PasswordHistoryState.ViewState.Error(
message = R.string.an_error_has_occurred.asText(),
),
passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = "mockId-1"),
)
val actualState = awaitItem()
assertEquals(expectedState, actualState)
}
}
@Test
fun `when VaultRepository emits Empty state the state updates correctly`() = runTest {
mutableVaultItemFlow.value = DataState.Loaded(null)
val viewModel = createViewModel(
initialState = createPasswordHistoryState(
passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = "mockId-1"),
),
)
viewModel.stateFlow.test {
val expectedState = createPasswordHistoryState(
viewState = PasswordHistoryState.ViewState.Empty,
passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = "mockId-1"),
)
val actualState = awaitItem()
assertEquals(expectedState, actualState)
}
}
@Test
fun `when VaultRepository emits Pending state the state updates correctly`() = runTest {
mutableVaultItemFlow.value = DataState.Pending(createMockCipherView(1))
val viewModel = createViewModel(
initialState = createPasswordHistoryState(
passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = "mockId-1"),
),
)
viewModel.stateFlow.test {
val expectedState = createPasswordHistoryState(
viewState = PasswordHistoryState.ViewState.Content(
passwords = listOf(
PasswordHistoryState.GeneratedPassword(
password = "mockPassword-1",
date = "10/27/23 6:00 AM",
),
),
),
passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = "mockId-1"),
)
val actualState = awaitItem() val actualState = awaitItem()
assertEquals(expectedState, actualState) assertEquals(expectedState, actualState)
} }
@ -82,7 +180,7 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() {
val passwordHistoryView = PasswordHistoryView("password", Instant.now()) val passwordHistoryView = PasswordHistoryView("password", Instant.now())
fakeGeneratorRepository.storePasswordHistory(passwordHistoryView) fakeGeneratorRepository.storePasswordHistory(passwordHistoryView)
val expectedState = PasswordHistoryState( val expectedState = createPasswordHistoryState(
viewState = PasswordHistoryState.ViewState.Content( viewState = PasswordHistoryState.ViewState.Content(
passwords = listOf( passwords = listOf(
PasswordHistoryState.GeneratedPassword( PasswordHistoryState.GeneratedPassword(
@ -146,10 +244,41 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() {
//region Helper Functions //region Helper Functions
private fun createViewModel(): PasswordHistoryViewModel = PasswordHistoryViewModel( private fun createViewModel(
initialState: PasswordHistoryState = createPasswordHistoryState(),
): PasswordHistoryViewModel = PasswordHistoryViewModel(
savedStateHandle = createSavedStateHandleWithState(state = initialState),
clipboardManager = clipboardManager, clipboardManager = clipboardManager,
generatorRepository = fakeGeneratorRepository, generatorRepository = fakeGeneratorRepository,
vaultRepository = fakeVaultRepository,
) )
private fun createPasswordHistoryState(
viewState: PasswordHistoryState.ViewState = PasswordHistoryState.ViewState.Loading,
passwordHistoryMode: GeneratorPasswordHistoryMode = GeneratorPasswordHistoryMode.Default,
): PasswordHistoryState =
PasswordHistoryState(
viewState = viewState,
passwordHistoryMode = passwordHistoryMode,
)
private fun createSavedStateHandleWithState(
state: PasswordHistoryState? = createPasswordHistoryState(),
passwordHistoryMode: GeneratorPasswordHistoryMode = GeneratorPasswordHistoryMode.Default,
) = SavedStateHandle().apply {
set("state", state)
set(
"password_history_mode",
when (passwordHistoryMode) {
is GeneratorPasswordHistoryMode.Default -> "default"
is GeneratorPasswordHistoryMode.Item -> "item"
},
)
set(
"password_history_id",
(passwordHistoryMode as? GeneratorPasswordHistoryMode.Item)?.itemId,
)
}
//endregion Helper Functions //endregion Helper Functions
} }

View file

@ -59,6 +59,7 @@ class VaultItemScreenTest : BaseComposeTest() {
private var onNavigateToVaultEditItemId: String? = null private var onNavigateToVaultEditItemId: String? = null
private var onNavigateToMoveToOrganizationItemId: String? = null private var onNavigateToMoveToOrganizationItemId: String? = null
private var onNavigateToAttachmentsId: String? = null private var onNavigateToAttachmentsId: String? = null
private var onNavigateToPasswordHistoryId: String? = null
private val intentManager = mockk<IntentManager>(relaxed = true) private val intentManager = mockk<IntentManager>(relaxed = true)
@ -80,6 +81,7 @@ class VaultItemScreenTest : BaseComposeTest() {
onNavigateToMoveToOrganizationItemId = id onNavigateToMoveToOrganizationItemId = id
}, },
onNavigateToAttachments = { onNavigateToAttachmentsId = it }, onNavigateToAttachments = { onNavigateToAttachmentsId = it },
onNavigateToPasswordHistory = { onNavigateToPasswordHistoryId = it },
intentManager = intentManager, intentManager = intentManager,
) )
} }
@ -107,6 +109,13 @@ class VaultItemScreenTest : BaseComposeTest() {
assertEquals(id, onNavigateToAttachmentsId) assertEquals(id, onNavigateToAttachmentsId)
} }
@Test
fun `NavigateToPasswordHistory event should invoke onNavigateToPasswordHistory`() {
val id = "id1234"
mutableEventFlow.tryEmit(VaultItemEvent.NavigateToPasswordHistory(itemId = id))
assertEquals(id, onNavigateToPasswordHistoryId)
}
@Test @Test
fun `on close click should send CloseClick`() { fun `on close click should send CloseClick`() {
composeTestRule.onNodeWithContentDescription(label = "Close").performClick() composeTestRule.onNodeWithContentDescription(label = "Close").performClick()

View file

@ -104,6 +104,8 @@ class VaultAddItemStateExtensionsTest {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `toCipherView should transform Login ItemType to CipherView with original cipher`() { fun `toCipherView should transform Login ItemType to CipherView with original cipher`() {
mockkStatic(Instant::class)
every { Instant.now() } returns Instant.MIN
val cipherView = DEFAULT_LOGIN_CIPHER_VIEW val cipherView = DEFAULT_LOGIN_CIPHER_VIEW
val viewState = VaultAddEditState.ViewState.Content( val viewState = VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common( common = VaultAddEditState.ViewState.Content.Common(
@ -147,7 +149,7 @@ class VaultAddItemStateExtensionsTest {
login = LoginView( login = LoginView(
username = "mockUsername-1", username = "mockUsername-1",
password = "mockPassword-1", password = "mockPassword-1",
passwordRevisionDate = Instant.ofEpochSecond(1_000L), passwordRevisionDate = Instant.MIN,
uris = listOf( uris = listOf(
LoginUriView( LoginUriView(
uri = "mockUri-1", uri = "mockUri-1",
@ -186,9 +188,13 @@ class VaultAddItemStateExtensionsTest {
), ),
), ),
passwordHistory = listOf( passwordHistory = listOf(
PasswordHistoryView(
password = "password",
lastUsedDate = Instant.MIN,
),
PasswordHistoryView( PasswordHistoryView(
password = "old_password", password = "old_password",
lastUsedDate = Instant.ofEpochSecond(1_000L), lastUsedDate = Instant.MIN,
), ),
), ),
), ),
@ -500,7 +506,7 @@ class VaultAddItemStateExtensionsTest {
passwordHistory = listOf( passwordHistory = listOf(
PasswordHistoryView( PasswordHistoryView(
password = "old_password", password = "old_password",
lastUsedDate = Instant.ofEpochSecond(1_000L), lastUsedDate = Instant.MIN,
), ),
), ),
), ),
@ -564,12 +570,12 @@ private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView(
passwordHistory = listOf( passwordHistory = listOf(
PasswordHistoryView( PasswordHistoryView(
password = "old_password", password = "old_password",
lastUsedDate = Instant.ofEpochSecond(1_000L), lastUsedDate = Instant.MIN,
), ),
), ),
creationDate = Instant.ofEpochSecond(1_000L), creationDate = Instant.MIN,
deletedDate = null, deletedDate = null,
revisionDate = Instant.ofEpochSecond(1_000L), revisionDate = Instant.MIN,
) )
private val DEFAULT_LOGIN_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy( private val DEFAULT_LOGIN_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy(
@ -577,7 +583,7 @@ private val DEFAULT_LOGIN_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.cop
login = LoginView( login = LoginView(
username = "username", username = "username",
password = "password", password = "password",
passwordRevisionDate = Instant.ofEpochSecond(1_000L), passwordRevisionDate = Instant.MIN,
uris = listOf( uris = listOf(
LoginUriView( LoginUriView(
uri = "www.example.com", uri = "www.example.com",