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.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 = {

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
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
}

View file

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

View file

@ -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<PasswordHistoryState, PasswordHistoryEvent, PasswordHistoryAction>(
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<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.
*
* @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<List<PasswordHistoryView>>,
) : 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,
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,
)
}
}

View file

@ -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())

View file

@ -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<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(
common: VaultAddEditState.ViewState.Content.Common,
): LoginView? =

View file

@ -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<PasswordHistoryEvent>()
private val mutableStateFlow = MutableStateFlow(
PasswordHistoryState(PasswordHistoryState.ViewState.Loading),
PasswordHistoryState(
passwordHistoryMode = GeneratorPasswordHistoryMode.Default,
viewState = PasswordHistoryState.ViewState.Loading,
),
)
private val viewModel = mockk<PasswordHistoryViewModel>(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()
}

View file

@ -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<CipherView?>>(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
}

View file

@ -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<IntentManager>(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()

View file

@ -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",