From 8156e306f58b8a17ebb5aaf1584481fece1b3de7 Mon Sep 17 00:00:00 2001 From: Ramsey Smith <142836716+ramsey-livefront@users.noreply.github.com> Date: Thu, 1 Feb 2024 09:16:07 -0700 Subject: [PATCH] BIT-617: Vault Password History (#935) --- .../vaultunlocked/VaultUnlockedNavigation.kt | 12 +- .../model/GeneratorPasswordHistoryMode.kt | 22 +++ .../PasswordHistoryNavigation.kt | 62 +++++++- .../passwordhistory/PasswordHistoryScreen.kt | 30 ++-- .../PasswordHistoryViewModel.kt | 102 ++++++++++--- .../vault/feature/item/VaultItemNavigation.kt | 2 + .../ui/vault/feature/item/VaultItemScreen.kt | 4 +- .../vault/util/VaultAddItemStateExtensions.kt | 23 ++- .../PasswordHistoryScreenTest.kt | 72 +++++++-- .../PasswordHistoryViewModelTest.kt | 143 +++++++++++++++++- .../vault/feature/item/VaultItemScreenTest.kt | 9 ++ .../util/VaultAddItemStateExtensionsTest.kt | 20 ++- 12 files changed, 427 insertions(+), 74 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/model/GeneratorPasswordHistoryMode.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index da453887dc..3c598491a4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -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.vaultUnlockedNavBarDestination 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.passwordhistory.navigateToPasswordHistory import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.passwordHistoryDestination @@ -90,7 +91,11 @@ fun NavGraphBuilder.vaultUnlockedGraph( onNavigateToEditSend = { navController.navigateToAddSend(AddSendType.EditItem(it)) }, onNavigateToDeleteAccount = { navController.navigateToDeleteAccount() }, onNavigateToPendingRequests = { navController.navigateToPendingRequests() }, - onNavigateToPasswordHistory = { navController.navigateToPasswordHistory() }, + onNavigateToPasswordHistory = { + navController.navigateToPasswordHistory( + passwordHistoryMode = GeneratorPasswordHistoryMode.Default, + ) + }, ) deleteAccountDestination(onNavigateBack = { navController.popBackStack() }) loginApprovalDestination(onNavigateBack = { navController.popBackStack() }) @@ -136,6 +141,11 @@ fun NavGraphBuilder.vaultUnlockedGraph( ) }, onNavigateToAttachments = { navController.navigateToAttachment(it) }, + onNavigateToPasswordHistory = { + navController.navigateToPasswordHistory( + passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = it), + ) + }, ) vaultQrCodeScanDestination( onNavigateToManualCodeEntryScreen = { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/model/GeneratorPasswordHistoryMode.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/model/GeneratorPasswordHistoryMode.kt new file mode 100644 index 0000000000..74447215ee --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/model/GeneratorPasswordHistoryMode.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryNavigation.kt index 5aad8a7c72..b69abee4b9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryNavigation.kt @@ -1,14 +1,45 @@ package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory +import androidx.lifecycle.SavedStateHandle import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder 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.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. @@ -17,8 +48,10 @@ fun NavGraphBuilder.passwordHistoryDestination( onNavigateBack: () -> Unit, ) { composableWithSlideTransitions( - // TODO: (BIT-617) Allow Password History screen to launch from VaultItemScreen route = PASSWORD_HISTORY_ROUTE, + arguments = listOf( + navArgument(PASSWORD_HISTORY_MODE) { type = NavType.StringType }, + ), ) { PasswordHistoryScreen( onNavigateBack = onNavigateBack, @@ -29,6 +62,25 @@ fun NavGraphBuilder.passwordHistoryDestination( /** * Navigate to the Password History Screen. */ -fun NavController.navigateToPasswordHistory(navOptions: NavOptions? = null) { - navigate(PASSWORD_HISTORY_ROUTE, navOptions) +fun NavController.navigateToPasswordHistory( + 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 + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreen.kt index e3f6e5b0e8..22e20c84c3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreen.kt @@ -82,21 +82,23 @@ fun PasswordHistoryScreen( { viewModel.trySendAction(PasswordHistoryAction.CloseClick) } }, actions = { - BitwardenOverflowActionItem( - menuItemDataList = persistentListOf( - OverflowMenuItemData( - testTag = "ClearPasswordList", - text = stringResource(id = R.string.clear), - onClick = remember(viewModel) { - { - viewModel.trySendAction( - PasswordHistoryAction.PasswordClearClick, - ) - } - }, + if (state.menuEnabled) { + BitwardenOverflowActionItem( + menuItemDataList = persistentListOf( + OverflowMenuItemData( + testTag = "ClearPasswordList", + text = stringResource(id = R.string.clear), + onClick = remember(viewModel) { + { + viewModel.trySendAction( + PasswordHistoryAction.PasswordClearClick, + ) + } + }, + ), ), - ), - ) + ) + } }, ) }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModel.kt index 4f68cf87ad..83a2c943ba 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModel.kt @@ -1,16 +1,21 @@ package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.bitwarden.core.CipherView import com.bitwarden.core.PasswordHistoryView import com.x8bit.bitwarden.R 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.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.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText 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 dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn @@ -21,24 +26,45 @@ import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject +private const val KEY_STATE = "state" + /** * ViewModel responsible for handling user interactions in the PasswordHistoryScreen. */ @HiltViewModel @Suppress("TooManyFunctions") class PasswordHistoryViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, private val clipboardManager: BitwardenClipboardManager, private val generatorRepository: GeneratorRepository, + private val vaultRepository: VaultRepository, ) : BaseViewModel( - initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading), + initialState = savedStateHandle[KEY_STATE] + ?: run { + PasswordHistoryState( + passwordHistoryMode = PasswordHistoryArgs(savedStateHandle).passwordHistoryMode, + viewState = PasswordHistoryState.ViewState.Loading, + ) + }, ) { init { - generatorRepository - .passwordHistoryStateFlow - .map { PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive(it) } - .onEach(::sendAction) - .launchIn(viewModelScope) + when (val passwordHistoryMode = state.passwordHistoryMode) { + is GeneratorPasswordHistoryMode.Default -> { + generatorRepository + .passwordHistoryStateFlow + .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) { @@ -49,6 +75,8 @@ class PasswordHistoryViewModel @Inject constructor( is PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive -> { 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()) } - is LocalDataState.Loaded -> { - 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) - } - } + is LocalDataState.Loaded -> state.data.toViewState() } 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() { sendEvent( event = PasswordHistoryEvent.NavigateBack, @@ -100,18 +128,41 @@ class PasswordHistoryViewModel @Inject constructor( private fun handleCopyClick(password: GeneratedPassword) { clipboardManager.setText(text = password.password) } + + private fun List?.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. * + * @property passwordHistoryMode Indicates whether tje VM is in default or item mode. * @property viewState The current view state of the password history screen. */ @Parcelize data class PasswordHistoryState( + val passwordHistoryMode: GeneratorPasswordHistoryMode, val viewState: ViewState, ) : 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. */ @@ -211,5 +262,12 @@ sealed class PasswordHistoryAction { data class UpdatePasswordHistoryReceive( val state: LocalDataState>, ) : Internal() + + /** + * Indicates cipher data is received. + */ + data class CipherDataReceive( + val state: DataState, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt index 273cfb7308..4f22eed7d9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt @@ -31,6 +31,7 @@ fun NavGraphBuilder.vaultItemDestination( onNavigateToVaultEditItem: (vaultItemId: String, isClone: Boolean) -> Unit, onNavigateToMoveToOrganization: (vaultItemId: String, showOnlyCollections: Boolean) -> Unit, onNavigateToAttachments: (vaultItemId: String) -> Unit, + onNavigateToPasswordHistory: (vaultItemId: String) -> Unit, ) { composableWithSlideTransitions( route = VAULT_ITEM_ROUTE, @@ -43,6 +44,7 @@ fun NavGraphBuilder.vaultItemDestination( onNavigateToVaultAddEditItem = onNavigateToVaultEditItem, onNavigateToMoveToOrganization = onNavigateToMoveToOrganization, onNavigateToAttachments = onNavigateToAttachments, + onNavigateToPasswordHistory = onNavigateToPasswordHistory, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index b663e3d36b..35dee70304 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -63,6 +63,7 @@ fun VaultItemScreen( onNavigateToVaultAddEditItem: (vaultItemId: String, isClone: Boolean) -> Unit, onNavigateToMoveToOrganization: (vaultItemId: String, showOnlyCollections: Boolean) -> Unit, onNavigateToAttachments: (vaultItemId: String) -> Unit, + onNavigateToPasswordHistory: (vaultItemId: String) -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current @@ -92,8 +93,7 @@ fun VaultItemScreen( } is VaultItemEvent.NavigateToPasswordHistory -> { - // TODO Implement password history in BIT-617 - Toast.makeText(context, "Not yet implemented.", Toast.LENGTH_SHORT).show() + onNavigateToPasswordHistory(event.itemId) } is VaultItemEvent.NavigateToUri -> intentManager.launchUri(event.uri.toUri()) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt index 76591e1157..ebeab74cef 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt @@ -9,6 +9,7 @@ import com.bitwarden.core.FieldView import com.bitwarden.core.IdentityView import com.bitwarden.core.LoginUriView import com.bitwarden.core.LoginView +import com.bitwarden.core.PasswordHistoryView import com.bitwarden.core.SecureNoteType import com.bitwarden.core.SecureNoteView import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank @@ -33,7 +34,7 @@ fun VaultAddEditState.ViewState.Content.toCipherView(): CipherView = localData = common.originalCipher?.localData, attachments = common.originalCipher?.attachments, organizationUseTotp = common.originalCipher?.organizationUseTotp ?: false, - passwordHistory = common.originalCipher?.passwordHistory, + passwordHistory = toPasswordHistory(), creationDate = common.originalCipher?.creationDate ?: Instant.now(), deletedDate = common.originalCipher?.deletedDate, 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? { + 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( common: VaultAddEditState.ViewState.Content.Common, ): LoginView? = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreenTest.kt index 11e2681daa..665aa5d8d9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreenTest.kt @@ -1,16 +1,23 @@ package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory 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.onNodeWithText import androidx.compose.ui.test.performClick import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest 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.mockk import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import org.junit.Before import org.junit.Test import org.junit.jupiter.api.Assertions.assertTrue @@ -21,7 +28,10 @@ class PasswordHistoryScreenTest : BaseComposeTest() { private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow( - PasswordHistoryState(PasswordHistoryState.ViewState.Loading), + PasswordHistoryState( + passwordHistoryMode = GeneratorPasswordHistoryMode.Default, + viewState = PasswordHistoryState.ViewState.Loading, + ), ) private val viewModel = mockk(relaxed = true) { @@ -41,16 +51,22 @@ class PasswordHistoryScreenTest : BaseComposeTest() { @Test 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() } @Test fun `Error state should display error message`() { val errorMessage = "Error occurred" - updateState( - PasswordHistoryState(PasswordHistoryState.ViewState.Error(errorMessage.asText())), - ) + mutableStateFlow.update { + it.copy( + viewState = PasswordHistoryState.ViewState.Error(errorMessage.asText()), + ) + } composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed() } @@ -74,13 +90,13 @@ class PasswordHistoryScreenTest : BaseComposeTest() { @Test fun `clicking the Copy button should send PasswordCopyClick action`() { val password = PasswordHistoryState.GeneratedPassword(password = "Password", date = "Date") - updateState( - PasswordHistoryState( - PasswordHistoryState.ViewState.Content( + mutableStateFlow.update { + it.copy( + viewState = PasswordHistoryState.ViewState.Content( passwords = listOf(password), ), - ), - ) + ) + } composeTestRule.onNodeWithText(password.password).assertIsDisplayed() 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 fun `Content state should display list of passwords`() { val passwords = listOf(PasswordHistoryState.GeneratedPassword(password = "Password1", date = "Date1")) - updateState( - PasswordHistoryState( - PasswordHistoryState.ViewState.Content( + mutableStateFlow.update { + it.copy( + viewState = PasswordHistoryState.ViewState.Content( passwords = passwords, ), - ), - ) + ) + } composeTestRule.onNodeWithText("Password1").assertIsDisplayed() } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModelTest.kt index 9cb8902921..4cf1fc28ff 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryViewModelTest.kt @@ -1,19 +1,26 @@ package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory +import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.bitwarden.core.CipherView import com.bitwarden.core.PasswordHistoryView import com.x8bit.bitwarden.R 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.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.util.asText 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.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue @@ -22,10 +29,14 @@ import java.time.Instant class PasswordHistoryViewModelTest : BaseViewModelTest() { - private val initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading) + private val initialState = createPasswordHistoryState() private val clipboardManager: BitwardenClipboardManager = mockk() private val fakeGeneratorRepository = FakeGeneratorRepository() + private val mutableVaultItemFlow = MutableStateFlow>(DataState.Loading) + private val fakeVaultRepository: VaultRepository = mockk { + every { getVaultItemStateFlow("mockId-1") } returns mutableVaultItemFlow + } @Test fun `initial state should be correct`() = runTest { @@ -41,7 +52,7 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() { val viewModel = createViewModel() viewModel.stateFlow.test { - val expectedState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading) + val expectedState = createPasswordHistoryState() val actualState = awaitItem() assertEquals(expectedState, actualState) } @@ -55,8 +66,10 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() { val viewModel = createViewModel() viewModel.stateFlow.test { - val expectedState = PasswordHistoryState( - PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText()), + val expectedState = createPasswordHistoryState( + viewState = PasswordHistoryState.ViewState.Error( + message = R.string.an_error_has_occurred.asText(), + ), ) val actualState = awaitItem() assertEquals(expectedState, actualState) @@ -69,7 +82,92 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() { val viewModel = createViewModel() 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() assertEquals(expectedState, actualState) } @@ -82,7 +180,7 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() { val passwordHistoryView = PasswordHistoryView("password", Instant.now()) fakeGeneratorRepository.storePasswordHistory(passwordHistoryView) - val expectedState = PasswordHistoryState( + val expectedState = createPasswordHistoryState( viewState = PasswordHistoryState.ViewState.Content( passwords = listOf( PasswordHistoryState.GeneratedPassword( @@ -146,10 +244,41 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() { //region Helper Functions - private fun createViewModel(): PasswordHistoryViewModel = PasswordHistoryViewModel( + private fun createViewModel( + initialState: PasswordHistoryState = createPasswordHistoryState(), + ): PasswordHistoryViewModel = PasswordHistoryViewModel( + savedStateHandle = createSavedStateHandleWithState(state = initialState), clipboardManager = clipboardManager, 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 } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 6576e2cb9d..ec2eb34b28 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -59,6 +59,7 @@ class VaultItemScreenTest : BaseComposeTest() { private var onNavigateToVaultEditItemId: String? = null private var onNavigateToMoveToOrganizationItemId: String? = null private var onNavigateToAttachmentsId: String? = null + private var onNavigateToPasswordHistoryId: String? = null private val intentManager = mockk(relaxed = true) @@ -80,6 +81,7 @@ class VaultItemScreenTest : BaseComposeTest() { onNavigateToMoveToOrganizationItemId = id }, onNavigateToAttachments = { onNavigateToAttachmentsId = it }, + onNavigateToPasswordHistory = { onNavigateToPasswordHistoryId = it }, intentManager = intentManager, ) } @@ -107,6 +109,13 @@ class VaultItemScreenTest : BaseComposeTest() { assertEquals(id, onNavigateToAttachmentsId) } + @Test + fun `NavigateToPasswordHistory event should invoke onNavigateToPasswordHistory`() { + val id = "id1234" + mutableEventFlow.tryEmit(VaultItemEvent.NavigateToPasswordHistory(itemId = id)) + assertEquals(id, onNavigateToPasswordHistoryId) + } + @Test fun `on close click should send CloseClick`() { composeTestRule.onNodeWithContentDescription(label = "Close").performClick() diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt index ef731bb7ea..fc3d5574d1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt @@ -104,6 +104,8 @@ class VaultAddItemStateExtensionsTest { @Suppress("MaxLineLength") @Test 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 viewState = VaultAddEditState.ViewState.Content( common = VaultAddEditState.ViewState.Content.Common( @@ -147,7 +149,7 @@ class VaultAddItemStateExtensionsTest { login = LoginView( username = "mockUsername-1", password = "mockPassword-1", - passwordRevisionDate = Instant.ofEpochSecond(1_000L), + passwordRevisionDate = Instant.MIN, uris = listOf( LoginUriView( uri = "mockUri-1", @@ -186,9 +188,13 @@ class VaultAddItemStateExtensionsTest { ), ), passwordHistory = listOf( + PasswordHistoryView( + password = "password", + lastUsedDate = Instant.MIN, + ), PasswordHistoryView( password = "old_password", - lastUsedDate = Instant.ofEpochSecond(1_000L), + lastUsedDate = Instant.MIN, ), ), ), @@ -500,7 +506,7 @@ class VaultAddItemStateExtensionsTest { passwordHistory = listOf( PasswordHistoryView( 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( PasswordHistoryView( password = "old_password", - lastUsedDate = Instant.ofEpochSecond(1_000L), + lastUsedDate = Instant.MIN, ), ), - creationDate = Instant.ofEpochSecond(1_000L), + creationDate = Instant.MIN, deletedDate = null, - revisionDate = Instant.ofEpochSecond(1_000L), + revisionDate = Instant.MIN, ) 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( username = "username", password = "password", - passwordRevisionDate = Instant.ofEpochSecond(1_000L), + passwordRevisionDate = Instant.MIN, uris = listOf( LoginUriView( uri = "www.example.com",