mirror of
https://github.com/bitwarden/android.git
synced 2024-11-26 11:26:09 +03:00
BIT-617: Vault Password History (#935)
This commit is contained in:
parent
46bc489f1f
commit
8156e306f5
12 changed files with 427 additions and 74 deletions
|
@ -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 = {
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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? =
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue