VULN-988: bug: Additional GMS validation for CXP export (#7367)

This commit is contained in:
David Perez
2026-09-15 17:08:05 +00:00
committed by GitHub
parent dfeeeb26bf
commit 1d5207c3cf
6 changed files with 847 additions and 328 deletions
@@ -14,7 +14,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.manager.util.toImportCredentialsRequestDataOrNull
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -37,11 +37,13 @@ class SelectAccountViewModel @Inject constructor(
specialCircumstanceManager: SpecialCircumstanceManager,
) : BaseViewModel<SelectAccountState, SelectAccountEvent, SelectAccountAction>(
initialState = run {
val importRequest = specialCircumstanceManager.specialCircumstance
as SpecialCircumstance.CredentialExchangeExport
val importRequest = requireNotNull(
specialCircumstanceManager
.specialCircumstance
?.toImportCredentialsRequestDataOrNull(),
)
SelectAccountState(
importRequest = importRequest.data,
importRequest = importRequest,
viewState = SelectAccountState.ViewState.Loading,
)
},
@@ -29,10 +29,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
import com.bitwarden.cxf.manager.model.ExportCredentialsResult
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeCompletionManager
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeRequestValidator
import com.bitwarden.cxf.validator.CredentialExchangeRequestValidator
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.content.BitwardenErrorContent
import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
@@ -47,12 +51,14 @@ import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.AccountSummaryListItem
import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.ExportItemsScaffold
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.handlers.VerifyPasswordHandlers
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.handlers.rememberVerifyPasswordHandler
/**
* Top level composable for the Verify Password screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun VerifyPasswordScreen(
onNavigateBack: () -> Unit,
@@ -60,6 +66,8 @@ fun VerifyPasswordScreen(
viewModel: VerifyPasswordViewModel = hiltViewModel(),
credentialExchangeCompletionManager: CredentialExchangeCompletionManager =
LocalCredentialExchangeCompletionManager.current,
credentialExchangeRequestValidator: CredentialExchangeRequestValidator =
LocalCredentialExchangeRequestValidator.current,
snackbarHostState: BitwardenSnackbarHostState = rememberBitwardenSnackbarHostState(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@@ -80,6 +88,16 @@ fun VerifyPasswordScreen(
)
}
is VerifyPasswordEvent.ValidateImportRequest -> {
viewModel.trySendAction(
VerifyPasswordAction.ValidateImportRequestResultReceive(
isValid = credentialExchangeRequestValidator.validate(
importCredentialsRequestData = event.importCredentialsRequestData,
),
),
)
}
is VerifyPasswordEvent.PasswordVerified -> {
onPasswordVerified(event.userId)
}
@@ -108,13 +126,30 @@ fun VerifyPasswordScreen(
scrollBehavior = scrollBehavior,
modifier = Modifier.fillMaxSize(),
) {
VerifyPasswordContent(
state = state,
onInputChanged = handler.onInputChanged,
onContinueClick = handler.onContinueClick,
onResendCodeClick = handler.onSendCodeClick,
modifier = Modifier.fillMaxSize(),
)
when (val viewState = state.viewState) {
is VerifyPasswordState.ViewState.Content -> {
VerifyPasswordContent(
viewState = viewState,
accountSummaryListItem = state.accountSummaryListItem,
handler = handler,
modifier = Modifier.fillMaxSize(),
)
}
is VerifyPasswordState.ViewState.Error -> {
BitwardenErrorContent(
message = viewState.message(),
modifier = Modifier.fillMaxSize(),
)
}
VerifyPasswordState.ViewState.Loading -> {
BitwardenLoadingContent(
text = stringResource(id = BitwardenString.loading),
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
@@ -144,10 +179,9 @@ private fun VerifyPasswordDialogs(
@Suppress("LongMethod")
@Composable
private fun VerifyPasswordContent(
state: VerifyPasswordState,
onInputChanged: (String) -> Unit,
onContinueClick: () -> Unit,
onResendCodeClick: () -> Unit,
viewState: VerifyPasswordState.ViewState.Content,
accountSummaryListItem: AccountSelectionListItem,
handler: VerifyPasswordHandlers,
modifier: Modifier = Modifier,
) {
Column(
@@ -158,7 +192,7 @@ private fun VerifyPasswordContent(
Spacer(Modifier.height(24.dp))
Text(
text = state.title(),
text = viewState.title(),
textAlign = TextAlign.Center,
style = BitwardenTheme.typography.titleMedium,
modifier = Modifier
@@ -166,7 +200,7 @@ private fun VerifyPasswordContent(
.standardHorizontalMargin(),
)
state.subtext?.let { subtext ->
viewState.subtext?.let { subtext ->
Spacer(Modifier.height(8.dp))
Text(
text = subtext(),
@@ -181,7 +215,7 @@ private fun VerifyPasswordContent(
Spacer(Modifier.height(16.dp))
AccountSummaryListItem(
item = state.accountSummaryListItem,
item = accountSummaryListItem,
cardStyle = CardStyle.Full,
clickable = false,
modifier = Modifier
@@ -191,17 +225,17 @@ private fun VerifyPasswordContent(
Spacer(Modifier.height(16.dp))
if (state.showResendCodeButton) {
if (viewState.showResendCodeButton) {
BitwardenPasswordField(
label = stringResource(id = BitwardenString.verification_code),
value = state.input,
onValueChange = onInputChanged,
value = viewState.input,
onValueChange = handler.onInputChanged,
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(
onDone = {
if (state.isContinueButtonEnabled) {
onContinueClick()
if (viewState.isContinueButtonEnabled) {
handler.onContinueClick()
} else {
defaultKeyboardAction(ImeAction.Done)
}
@@ -218,14 +252,14 @@ private fun VerifyPasswordContent(
} else {
BitwardenPasswordField(
label = stringResource(BitwardenString.master_password),
value = state.input,
onValueChange = onInputChanged,
value = viewState.input,
onValueChange = handler.onInputChanged,
showPasswordTestTag = "PasswordVisibilityToggle",
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(
onDone = {
if (state.isContinueButtonEnabled) {
onContinueClick()
if (viewState.isContinueButtonEnabled) {
handler.onContinueClick()
} else {
defaultKeyboardAction(ImeAction.Done)
}
@@ -245,18 +279,18 @@ private fun VerifyPasswordContent(
BitwardenFilledButton(
label = stringResource(BitwardenString.continue_text),
onClick = onContinueClick,
isEnabled = state.isContinueButtonEnabled,
onClick = handler.onContinueClick,
isEnabled = viewState.isContinueButtonEnabled,
modifier = Modifier
.testTag("ContinueImportButton")
.fillMaxWidth()
.standardHorizontalMargin(),
)
if (state.showResendCodeButton) {
if (viewState.showResendCodeButton) {
BitwardenOutlinedButton(
label = stringResource(BitwardenString.resend_code),
onClick = onResendCodeClick,
onClick = handler.onSendCodeClick,
modifier = Modifier
.testTag("ResendTOTPCodeButton")
.fillMaxWidth()
@@ -273,35 +307,28 @@ private fun VerifyPasswordContent(
@Preview(showBackground = true)
@Composable
private fun VerifyPasswordContent_MasterPassword_preview() {
val accountSummaryListItem = AccountSelectionListItem(
userId = "userId",
isItemRestricted = false,
avatarColorHex = "#FF0000",
initials = "JD",
email = "john.doe@example.com",
)
val state = VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
hasOtherAccounts = true,
accountSummaryListItem = accountSummaryListItem,
)
ExportItemsScaffold(
navIcon = rememberVectorPainter(
BitwardenDrawable.ic_back,
),
navIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
onNavigationIconClick = {},
navigationIconContentDescription = stringResource(BitwardenString.back),
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
modifier = Modifier.fillMaxSize(),
) {
VerifyPasswordContent(
state = state,
onInputChanged = {},
onContinueClick = {},
onResendCodeClick = {},
modifier = Modifier
.fillMaxSize(),
viewState = VerifyPasswordState.ViewState.Content(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
showResendCodeButton = false,
),
accountSummaryListItem = AccountSelectionListItem(
userId = "userId",
isItemRestricted = false,
avatarColorHex = "#FF0000",
initials = "JD",
email = "john.doe@example.com",
),
handler = VerifyPasswordHandlers.createEmpty(),
modifier = Modifier.fillMaxSize(),
)
}
}
@@ -310,38 +337,30 @@ private fun VerifyPasswordContent_MasterPassword_preview() {
@Preview(showBackground = true)
@Composable
private fun VerifyPasswordContent_Otp_preview() {
val accountSummaryListItem = AccountSelectionListItem(
userId = "userId",
isItemRestricted = false,
avatarColorHex = "#FF0000",
initials = "JD",
email = "john.doe@example.com",
)
val state = VerifyPasswordState(
title = BitwardenString.verify_your_account_email_address.asText(),
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText(),
accountSummaryListItem = accountSummaryListItem,
showResendCodeButton = true,
hasOtherAccounts = true,
)
ExportItemsScaffold(
navIcon = rememberVectorPainter(
BitwardenDrawable.ic_back,
),
navIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
onNavigationIconClick = {},
navigationIconContentDescription = stringResource(BitwardenString.back),
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
modifier = Modifier.fillMaxSize(),
) {
VerifyPasswordContent(
state = state,
onInputChanged = {},
onContinueClick = {},
onResendCodeClick = {},
modifier = Modifier
.fillMaxSize(),
viewState = VerifyPasswordState.ViewState.Content(
title = BitwardenString.verify_your_account_email_address.asText(),
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText(),
showResendCodeButton = true,
),
accountSummaryListItem = AccountSelectionListItem(
userId = "userId",
isItemRestricted = false,
avatarColorHex = "#FF0000",
initials = "JD",
email = "john.doe@example.com",
),
handler = VerifyPasswordHandlers.createEmpty(),
modifier = Modifier.fillMaxSize(),
)
}
}
@@ -3,7 +3,9 @@ package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.bitwarden.policies.PolicyType
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.resource.BitwardenString
@@ -15,6 +17,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.util.toImportCredentialsRequestDataOrNull
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
@@ -44,11 +48,17 @@ class VerifyPasswordViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
private val policyManager: PolicyManager,
specialCircumstanceManager: SpecialCircumstanceManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<VerifyPasswordState, VerifyPasswordEvent, VerifyPasswordAction>(
initialState = savedStateHandle[KEY_STATE]
?: run {
val args = savedStateHandle.toVerifyPasswordArgs()
val importRequest = requireNotNull(
specialCircumstanceManager
.specialCircumstance
?.toImportCredentialsRequestDataOrNull(),
)
val account = authRepository
.userStateFlow
.value
@@ -56,23 +66,15 @@ class VerifyPasswordViewModel @Inject constructor(
?.firstOrNull { it.userId == args.userId }
?: throw IllegalStateException("Account not found")
val singleAccount = !args.hasOtherAccounts
val restrictedItemPolicyOrgIds = policyManager
.getActivePolicies(PolicyType.RESTRICTED_ITEM_TYPES)
.filter { it.enabled }
.map { it.organizationId }
VerifyPasswordState(
title = if (account.hasMasterPassword) {
BitwardenString.verify_your_master_password.asText()
} else {
BitwardenString.verify_your_account_email_address.asText()
},
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText()
.takeUnless { account.hasMasterPassword },
importRequest = importRequest,
viewState = VerifyPasswordState.ViewState.Loading,
dialog = null,
accountSummaryListItem = AccountSelectionListItem(
userId = args.userId,
avatarColorHex = account.avatarColorHex,
@@ -82,8 +84,8 @@ class VerifyPasswordViewModel @Inject constructor(
.organizations
.any { it.id in restrictedItemPolicyOrgIds },
),
showResendCodeButton = !account.hasMasterPassword,
hasOtherAccounts = !singleAccount,
hasMasterPassword = account.hasMasterPassword,
hasOtherAccounts = args.hasOtherAccounts,
)
},
) {
@@ -94,49 +96,32 @@ class VerifyPasswordViewModel @Inject constructor(
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
if (stateFlow.value.showResendCodeButton) {
viewModelScope.launch {
sendAction(
VerifyPasswordAction.Internal.SendOtpCodeResultReceive(
result = authRepository.requestOneTimePasscode(),
),
)
}
}
sendEvent(
event = VerifyPasswordEvent.ValidateImportRequest(
importCredentialsRequestData = state.importRequest,
),
)
}
override fun onCleared() {
// TODO: This is required because there is an OS-level leak occurring that leaves the
// ViewModel in memory. We should remove this when that leak is fixed. (BIT-2287)
mutableStateFlow.update { it.copy(input = "") }
updateContent { it.copy(input = "") }
super.onCleared()
}
override fun handleAction(action: VerifyPasswordAction) {
when (action) {
VerifyPasswordAction.NavigateBackClick -> {
handleNavigateBackClick()
VerifyPasswordAction.NavigateBackClick -> handleNavigateBackClick()
VerifyPasswordAction.ContinueClick -> handleContinueClick()
is VerifyPasswordAction.PasswordInputChangeReceive -> handlePasswordInputChange(action)
VerifyPasswordAction.DismissDialog -> handleDismissDialog()
VerifyPasswordAction.ResendCodeClick -> handleResendCodeClick()
is VerifyPasswordAction.ValidateImportRequestResultReceive -> {
handleValidateImportRequestResultReceive(action)
}
VerifyPasswordAction.ContinueClick -> {
handleContinueClick()
}
is VerifyPasswordAction.PasswordInputChangeReceive -> {
handlePasswordInputChange(action)
}
VerifyPasswordAction.DismissDialog -> {
handleDismissDialog()
}
VerifyPasswordAction.ResendCodeClick -> {
handleResendCodeClick()
}
is VerifyPasswordAction.Internal -> {
handleInternalAction(action)
}
is VerifyPasswordAction.Internal -> handleInternalAction(action)
}
}
@@ -149,39 +134,41 @@ class VerifyPasswordViewModel @Inject constructor(
}
private fun handleContinueClick() {
if (state.input.isBlank()) {
onContent { content ->
if (content.input.isBlank()) {
mutableStateFlow.update {
it.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.validation_field_required.asText(
BitwardenString.master_password.asText(),
),
),
)
}
return@onContent
}
mutableStateFlow.update {
it.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.validation_field_required.asText(
BitwardenString.master_password.asText(),
),
dialog = VerifyPasswordState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
)
}
return
}
mutableStateFlow.update {
it.copy(
dialog = VerifyPasswordState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
)
}
if (authRepository.activeUserId != state.accountSummaryListItem.userId) {
switchAccountAndVerifyPassword()
} else {
validatePassword()
if (authRepository.activeUserId != state.accountSummaryListItem.userId) {
switchAccountAndVerifyPassword()
} else {
validatePassword()
}
}
}
private fun handlePasswordInputChange(
action: VerifyPasswordAction.PasswordInputChangeReceive,
) {
mutableStateFlow.update { it.copy(input = action.input) }
updateContent { it.copy(input = action.input) }
}
private fun handleDismissDialog() {
@@ -205,6 +192,44 @@ class VerifyPasswordViewModel @Inject constructor(
}
}
private fun handleValidateImportRequestResultReceive(
action: VerifyPasswordAction.ValidateImportRequestResultReceive,
) {
mutableStateFlow.update {
it.copy(
viewState = if (action.isValid) {
VerifyPasswordState.ViewState.Content(
title = if (state.hasMasterPassword) {
BitwardenString.verify_your_master_password.asText()
} else {
BitwardenString.verify_your_account_email_address.asText()
},
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText()
.takeUnless { state.hasMasterPassword },
showResendCodeButton = !state.hasMasterPassword,
)
} else {
VerifyPasswordState.ViewState.Error(
message = BitwardenString
.the_import_request_could_not_be_processed
.asText(),
)
},
)
}
if (action.isValid && !state.hasMasterPassword) {
viewModelScope.launch {
sendAction(
VerifyPasswordAction.Internal.SendOtpCodeResultReceive(
result = authRepository.requestOneTimePasscode(),
),
)
}
}
}
private fun handleInternalAction(action: VerifyPasswordAction.Internal) {
when (action) {
is VerifyPasswordAction.Internal.ValidatePasswordResultReceive -> {
@@ -313,12 +338,9 @@ class VerifyPasswordViewModel @Inject constructor(
) {
when (action.result) {
is VerifyOtpResult.Verified -> {
mutableStateFlow.update { it.copy(input = "", dialog = null) }
sendEvent(
VerifyPasswordEvent.PasswordVerified(
state.accountSummaryListItem.userId,
),
)
updateContent { it.copy(input = "") }
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(VerifyPasswordEvent.PasswordVerified(state.accountSummaryListItem.userId))
}
is VerifyOtpResult.NotVerified -> {
@@ -354,33 +376,36 @@ class VerifyPasswordViewModel @Inject constructor(
}
private fun validatePassword() {
val userId = state.accountSummaryListItem.userId
onContent {
val userId = state.accountSummaryListItem.userId
viewModelScope.launch {
if (state.showResendCodeButton) {
sendAction(
VerifyPasswordAction.Internal.VerifyOtpResultReceive(
result = authRepository.verifyOneTimePasscode(
oneTimePasscode = state.input,
viewModelScope.launch {
if (!state.hasMasterPassword) {
sendAction(
VerifyPasswordAction.Internal.VerifyOtpResultReceive(
result = authRepository.verifyOneTimePasscode(
oneTimePasscode = it.input,
),
),
),
)
} else if (vaultRepository.isVaultUnlocked(userId)) {
// If the vault is already unlocked, validate the password directly.
sendAction(
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
authRepository.validatePassword(password = state.input),
),
)
} else {
// Otherwise, unlock the vault with the provided password. The unlock result will
// indicate whether the password is correct.
sendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
vaultRepository
.unlockVaultWithMasterPassword(masterPassword = state.input),
),
)
)
} else if (vaultRepository.isVaultUnlocked(userId)) {
// If the vault is already unlocked, validate the password directly.
sendAction(
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
result = authRepository.validatePassword(password = it.input),
),
)
} else {
// Otherwise, unlock the vault with the provided password. The unlock result
// will indicate whether the password is correct.
sendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
vaultUnlockResult = vaultRepository.unlockVaultWithMasterPassword(
masterPassword = it.input,
),
),
)
}
}
}
}
@@ -412,35 +437,96 @@ class VerifyPasswordViewModel @Inject constructor(
}
private fun clearInputs() {
mutableStateFlow.update { it.copy(input = "") }
updateContent { it.copy(input = "") }
}
private inline fun onContent(
crossinline block: (VerifyPasswordState.ViewState.Content) -> Unit,
) {
(state.viewState as? VerifyPasswordState.ViewState.Content)?.let(block)
}
private inline fun updateContent(
crossinline block: (
VerifyPasswordState.ViewState.Content,
) -> VerifyPasswordState.ViewState.Content?,
) {
val currentViewState = state.viewState
val updatedContent = (currentViewState as? VerifyPasswordState.ViewState.Content)
?.let(block)
?: return
mutableStateFlow.update { it.copy(viewState = updatedContent) }
}
}
/**
* Represents the state of the VerifyPassword screen.
* @param accountSummaryListItem The account summary to display.
* @param input The current password input.
*
* @param importRequest The import request that verification is being performed for.
* @param viewState The current view state of the screen.
* @param dialog The current dialog state, or null if no dialog is shown.
* @param showResendCodeButton Whether to show the send code button.
* @param accountSummaryListItem The account summary to display.
* @param hasOtherAccounts Whether other accounts are available to export from. When false,
* navigating back cancels the export instead of returning to account selection.
* @param hasMasterPassword Whether the account has a master password. When false, verification is
* performed with a one-time passcode sent to the account email address.
*/
@Parcelize
data class VerifyPasswordState(
val importRequest: ImportCredentialsRequestData,
val viewState: ViewState,
val dialog: DialogState?,
val accountSummaryListItem: AccountSelectionListItem,
val title: Text,
val subtext: Text?,
val hasOtherAccounts: Boolean,
// We never want this saved since the input is sensitive data.
@IgnoredOnParcel
val input: String = "",
val dialog: DialogState? = null,
val showResendCodeButton: Boolean = false,
val hasMasterPassword: Boolean,
) : Parcelable {
/**
* Whether the unlock button should be enabled.
* Represents the different states for the verify password screen.
*/
val isContinueButtonEnabled: Boolean
get() = input.isNotBlank() && dialog !is DialogState.Loading
@Parcelize
sealed class ViewState : Parcelable {
/**
* Represents the loading state for the verify password screen. This is the state until the
* import request has been validated.
*/
@Parcelize
data object Loading : ViewState()
/**
* Represents the content state for the verify password screen.
*
* @param title The title to display.
* @param subtext The subtext to display below the title, or null if there is none.
* @param showResendCodeButton Whether to show the resend code button. This is only shown
* when verifying with a one-time passcode.
* @param input The current master password or one-time passcode input. This is never
* persisted since it is sensitive data.
*/
@Parcelize
data class Content(
val title: Text,
val subtext: Text?,
val showResendCodeButton: Boolean,
// We never want this saved since the input is sensitive data.
@IgnoredOnParcel
val input: String = "",
) : ViewState() {
/**
* Whether the continue button should be enabled.
*/
val isContinueButtonEnabled: Boolean get() = input.isNotBlank()
}
/**
* Represents the error state for the verify password screen.
*
* @param message The error message to display.
*/
@Parcelize
data class Error(
val message: Text,
) : ViewState()
}
/**
* Represents the state of a dialog.
@@ -484,6 +570,13 @@ sealed class VerifyPasswordEvent {
*/
data class PasswordVerified(val userId: String) : VerifyPasswordEvent()
/**
* Validates the import request.
*/
data class ValidateImportRequest(
val importCredentialsRequestData: ImportCredentialsRequestData,
) : VerifyPasswordEvent(), BackgroundEvent
/**
* Cancel the export request.
*/
@@ -541,6 +634,13 @@ sealed class VerifyPasswordAction {
*/
data class PasswordInputChangeReceive(val input: String) : VerifyPasswordAction()
/**
* Indicates the validate import request result was received.
*
* @param isValid Whether the import request is valid.
*/
data class ValidateImportRequestResultReceive(val isValid: Boolean) : VerifyPasswordAction()
/**
* Represents internal actions that the VerifyPasswordViewModel itself may send.
*/
@@ -42,6 +42,19 @@ data class VerifyPasswordHandlers(
viewModel.trySendAction(VerifyPasswordAction.DismissDialog)
},
)
/**
* Creates an empty [VerifyPasswordHandlers] that does nothing. This should only be used
* for previews.
*/
fun createEmpty(): VerifyPasswordHandlers =
VerifyPasswordHandlers(
onNavigateBackClick = { },
onContinueClick = { },
onInputChanged = { },
onSendCodeClick = { },
onDismissDialog = { },
)
}
}
@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword
import android.net.Uri
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
@@ -12,10 +13,15 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
import com.bitwarden.cxf.manager.model.ExportCredentialsResult
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.bitwarden.cxf.validator.CredentialExchangeRequestValidator
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.createMockOrganization
@@ -27,6 +33,7 @@ import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
@@ -41,6 +48,13 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() {
private var onPasswordVerifiedClicked: Boolean = false
private val onPasswordVerifiedArgSlot = mutableListOf<String>()
private val credentialExchangeCompletionManager =
mockk<CredentialExchangeCompletionManager> {
every { completeCredentialExport(exportResult = any()) } just runs
}
private val credentialExchangeRequestValidator = mockk<CredentialExchangeRequestValidator> {
every { validate(importCredentialsRequestData = any()) } returns true
}
private val mockStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mockEventFlow = bufferedMutableSharedFlow<VerifyPasswordEvent>()
private val viewModel = mockk<VerifyPasswordViewModel> {
@@ -51,7 +65,10 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() {
@Before
fun verifyPasswordScreen() {
setContent {
setContent(
credentialExchangeCompletionManager = credentialExchangeCompletionManager,
credentialExchangeRequestValidator = credentialExchangeRequestValidator,
) {
VerifyPasswordScreen(
onNavigateBack = { onNavigateBackClicked = true },
onPasswordVerified = { userId ->
@@ -83,17 +100,45 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() {
}
@Test
fun `otp state should be correct`() = runTest {
fun `loading state should be correct`() = runTest {
mockStateFlow.emit(
DEFAULT_STATE.copy(viewState = VerifyPasswordState.ViewState.Loading),
)
composeTestRule
.onNodeWithText(text = "Loading")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Verify your master password")
.assertDoesNotExist()
}
@Test
fun `error state should be correct`() = runTest {
mockStateFlow.emit(
DEFAULT_STATE.copy(
title = BitwardenString.verify_your_account_email_address.asText(),
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText(),
showResendCodeButton = true,
viewState = VerifyPasswordState.ViewState.Error(
message = BitwardenString
.the_import_request_could_not_be_processed
.asText(),
),
),
)
composeTestRule
.onNodeWithText(text = "The import request could not be processed.")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Continue")
.assertDoesNotExist()
}
@Test
fun `otp state should be correct`() = runTest {
mockStateFlow.emit(DEFAULT_STATE.copy(viewState = OTP_CONTENT_VIEW_STATE))
composeTestRule
.onNodeWithText("Verify your account email address")
.assertIsDisplayed()
@@ -125,7 +170,11 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() {
.onNodeWithText("Continue")
.assertIsNotEnabled()
mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123"))
mockStateFlow.emit(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(input = "abc123"),
),
)
composeTestRule
.onNodeWithText("Continue")
@@ -134,7 +183,11 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() {
@Test
fun `Continue button should send ContinueClick action`() = runTest {
mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123"))
mockStateFlow.emit(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(input = "abc123"),
),
)
composeTestRule
.onNodeWithText("Continue")
.performClick()
@@ -145,7 +198,7 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() {
@Test
fun `Resend code button should send SendCodeClick action`() = runTest {
mockStateFlow.emit(DEFAULT_STATE.copy(showResendCodeButton = true))
mockStateFlow.emit(DEFAULT_STATE.copy(viewState = OTP_CONTENT_VIEW_STATE))
composeTestRule
.onNodeWithText("Resend code")
.performClick()
@@ -178,8 +231,69 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() {
assertEquals(DEFAULT_USER_ID, onPasswordVerifiedArgSlot.first())
}
@Test
fun `CancelExport event should complete credential exchange with cancellation error`() =
runTest {
val exportResultSlot = slot<ExportCredentialsResult>()
every {
credentialExchangeCompletionManager.completeCredentialExport(
exportResult = capture(exportResultSlot),
)
} just runs
mockEventFlow.emit(VerifyPasswordEvent.CancelExport)
verify {
credentialExchangeCompletionManager.completeCredentialExport(
exportResult = exportResultSlot.captured,
)
}
assertTrue(exportResultSlot.captured is ExportCredentialsResult.Failure)
}
@Suppress("MaxLineLength")
@Test
fun `ValidateImportRequest event should send ValidateImportRequestResultReceive with validation result`() =
runTest {
mockEventFlow.emit(
VerifyPasswordEvent.ValidateImportRequest(
importCredentialsRequestData = DEFAULT_IMPORT_REQUEST,
),
)
verify {
credentialExchangeRequestValidator.validate(
importCredentialsRequestData = DEFAULT_IMPORT_REQUEST,
)
viewModel.trySendAction(
VerifyPasswordAction.ValidateImportRequestResultReceive(isValid = true),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `ValidateImportRequest event should send ValidateImportRequestResultReceive when request is invalid`() =
runTest {
every { credentialExchangeRequestValidator.validate(any()) } returns false
mockEventFlow.emit(
VerifyPasswordEvent.ValidateImportRequest(
importCredentialsRequestData = DEFAULT_IMPORT_REQUEST,
),
)
verify {
viewModel.trySendAction(
VerifyPasswordAction.ValidateImportRequestResultReceive(isValid = false),
)
}
}
@Test
fun `General dialog should display based on state`() = runTest {
composeTestRule.assertNoDialogExists()
mockStateFlow.emit(
DEFAULT_STATE.copy(
dialog = VerifyPasswordState.DialogState.General(
@@ -217,6 +331,8 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() {
@Test
fun `Loading dialog should display based on state`() = runTest {
composeTestRule.assertNoDialogExists()
mockStateFlow.emit(
DEFAULT_STATE.copy(
dialog = VerifyPasswordState.DialogState.Loading("message".asText()),
@@ -231,6 +347,11 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() {
private const val DEFAULT_USER_ID: String = "activeUserId"
private const val DEFAULT_ORGANIZATION_ID: String = "activeOrganizationId"
private val DEFAULT_IMPORT_REQUEST = ImportCredentialsRequestData(
uri = mockk<Uri>(),
credentialTypes = setOf("mockCredentialType-1"),
knownExtensions = setOf(),
)
private val DEFAULT_USER_STATE = UserState(
activeUserId = DEFAULT_USER_ID,
accounts = listOf(
@@ -273,11 +394,25 @@ private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem(
isItemRestricted = false,
initials = DEFAULT_USER_STATE.activeAccount.initials,
)
private val DEFAULT_STATE = VerifyPasswordState(
private val DEFAULT_CONTENT_VIEW_STATE = VerifyPasswordState.ViewState.Content(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
showResendCodeButton = false,
input = "",
)
private val OTP_CONTENT_VIEW_STATE = VerifyPasswordState.ViewState.Content(
title = BitwardenString.verify_your_account_email_address.asText(),
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText(),
showResendCodeButton = true,
input = "",
)
private val DEFAULT_STATE = VerifyPasswordState(
importRequest = DEFAULT_IMPORT_REQUEST,
viewState = DEFAULT_CONTENT_VIEW_STATE,
dialog = null,
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
hasOtherAccounts = true,
hasMasterPassword = true,
)
@@ -1,7 +1,10 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.policies.PolicyType
@@ -17,7 +20,9 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.createMockOrganization
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPolicyView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
@@ -38,6 +43,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class VerifyPasswordViewModelTest : BaseViewModelTest() {
@@ -61,6 +67,11 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
),
)
}
private val specialCircumstanceManager = mockk<SpecialCircumstanceManager> {
every {
specialCircumstance
} returns SpecialCircumstance.CredentialExchangeExport(data = DEFAULT_IMPORT_REQUEST)
}
@BeforeEach
fun setUp() {
@@ -78,6 +89,25 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Nested
inner class State {
@Test
fun `initial state should be correct when account has a master password`() = runTest {
createViewModel()
.also {
assertEquals(
VerifyPasswordState(
importRequest = DEFAULT_IMPORT_REQUEST,
viewState = VerifyPasswordState.ViewState.Loading,
dialog = null,
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
hasOtherAccounts = true,
hasMasterPassword = true,
),
it.stateFlow.value,
)
coVerify(exactly = 0) { authRepository.requestOneTimePasscode() }
}
}
@Test
fun `initial state should be correct when account has no master password`() = runTest {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
@@ -85,51 +115,20 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
it.copy(hasMasterPassword = false)
},
)
coEvery { authRepository.requestOneTimePasscode() } returns RequestOtpResult.Success
createViewModel()
.also {
assertEquals(
VerifyPasswordState(
title = BitwardenString.verify_your_account_email_address.asText(),
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText(),
accountSummaryListItem = AccountSelectionListItem(
userId = DEFAULT_USER_ID,
email = DEFAULT_USER_STATE.activeAccount.email,
avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex,
isItemRestricted = false,
initials = DEFAULT_USER_STATE.activeAccount.initials,
),
showResendCodeButton = true,
importRequest = DEFAULT_IMPORT_REQUEST,
viewState = VerifyPasswordState.ViewState.Loading,
dialog = null,
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
hasOtherAccounts = true,
hasMasterPassword = false,
),
it.stateFlow.value,
)
coVerify { authRepository.requestOneTimePasscode() }
}
}
@Test
fun `initial state should be correct when account is not restricted`() = runTest {
createViewModel()
.also {
assertEquals(
VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
hasOtherAccounts = true,
accountSummaryListItem = AccountSelectionListItem(
userId = DEFAULT_USER_ID,
email = DEFAULT_USER_STATE.activeAccount.email,
avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex,
isItemRestricted = false,
initials = DEFAULT_USER_STATE.activeAccount.initials,
),
),
it.stateFlow.value,
)
coVerify(exactly = 0) { authRepository.requestOneTimePasscode() }
}
}
@@ -147,10 +146,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
createViewModel()
.also {
assertEquals(
VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
hasOtherAccounts = true,
DEFAULT_LOADING_STATE.copy(
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
.copy(isItemRestricted = true),
),
@@ -158,19 +154,208 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
)
}
}
@Suppress("MaxLineLength")
@Test
fun `initial state should throw when special circumstance is not a credential exchange export`() {
every { specialCircumstanceManager.specialCircumstance } returns null
assertThrows<IllegalArgumentException> { createViewModel() }
}
@Test
fun `initial state should throw when the account cannot be found`() {
assertThrows<IllegalStateException> { createViewModel(userId = "unknownUserId") }
}
@Test
fun `initial state should be restored from the saved state handle`() = runTest {
val savedState = DEFAULT_STATE.copy(hasOtherAccounts = false)
createViewModel(state = savedState)
.also { assertEquals(savedState, it.stateFlow.value) }
}
}
@Nested
inner class ImportRequestValidation {
@Test
fun `initial load should emit ValidateImportRequest event`() = runTest {
createViewModel().also { viewModel ->
viewModel.eventFlow.test {
assertEquals(
VerifyPasswordEvent.ValidateImportRequest(
importCredentialsRequestData = DEFAULT_IMPORT_REQUEST,
),
awaitItem(),
)
}
}
}
@Suppress("MaxLineLength")
@Test
fun `ValidateImportRequestResultReceive with valid request should show master password content`() =
runTest {
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.ValidateImportRequestResultReceive(isValid = true),
)
assertEquals(
DEFAULT_LOADING_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE),
viewModel.stateFlow.value,
)
coVerify(exactly = 0) { authRepository.requestOneTimePasscode() }
}
}
@Suppress("MaxLineLength")
@Test
fun `ValidateImportRequestResultReceive with valid request should show otp content when account has no master password`() =
runTest {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = DEFAULT_USER_STATE.accounts.map {
it.copy(hasMasterPassword = false)
},
)
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.ValidateImportRequestResultReceive(isValid = true),
)
assertEquals(
DEFAULT_LOADING_STATE.copy(
viewState = OTP_CONTENT_VIEW_STATE,
hasMasterPassword = false,
),
viewModel.stateFlow.value,
)
coVerify(exactly = 1) { authRepository.requestOneTimePasscode() }
}
}
@Test
fun `ValidateImportRequestResultReceive with invalid request should show error content`() =
runTest {
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.ValidateImportRequestResultReceive(isValid = false),
)
assertEquals(
DEFAULT_LOADING_STATE.copy(
viewState = VerifyPasswordState.ViewState.Error(
message = BitwardenString
.the_import_request_could_not_be_processed
.asText(),
),
),
viewModel.stateFlow.value,
)
coVerify(exactly = 0) { authRepository.requestOneTimePasscode() }
}
}
@Suppress("MaxLineLength")
@Test
fun `ValidateImportRequestResultReceive with invalid request should not request otp code when account has no master password`() =
runTest {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = DEFAULT_USER_STATE.accounts.map {
it.copy(hasMasterPassword = false)
},
)
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.ValidateImportRequestResultReceive(isValid = false),
)
assertEquals(
DEFAULT_LOADING_STATE.copy(
viewState = VerifyPasswordState.ViewState.Error(
message = BitwardenString
.the_import_request_could_not_be_processed
.asText(),
),
hasMasterPassword = false,
),
viewModel.stateFlow.value,
)
coVerify(exactly = 0) { authRepository.requestOneTimePasscode() }
}
}
@Suppress("MaxLineLength")
@Test
fun `ValidateImportRequestResultReceive with valid request should show snackbar when otp code is sent`() =
runTest {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = DEFAULT_USER_STATE.accounts.map {
it.copy(hasMasterPassword = false)
},
)
coEvery { authRepository.requestOneTimePasscode() } returns RequestOtpResult.Success
createViewModel().also { viewModel ->
viewModel.eventFlow.test {
awaitValidateImportRequestEvent()
viewModel.trySendAction(
VerifyPasswordAction.ValidateImportRequestResultReceive(isValid = true),
)
assertEquals(
VerifyPasswordEvent.ShowSnackbar(BitwardenString.code_sent.asText()),
awaitItem(),
)
}
}
}
@Suppress("MaxLineLength")
@Test
fun `ValidateImportRequestResultReceive with valid request should show error dialog when otp code request fails`() =
runTest {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = DEFAULT_USER_STATE.accounts.map {
it.copy(hasMasterPassword = false)
},
)
coEvery {
authRepository.requestOneTimePasscode()
} returns RequestOtpResult.Error(message = "error", error = IllegalStateException())
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.ValidateImportRequestResultReceive(isValid = true),
)
assertEquals(
DEFAULT_LOADING_STATE.copy(
viewState = OTP_CONTENT_VIEW_STATE,
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = "error".asText(),
),
hasMasterPassword = false,
),
viewModel.stateFlow.value,
)
}
}
}
@Nested
inner class ViewActions {
@Test
fun `SendCodeClick should request otp code`() = runTest {
fun `SendCodeClick should show loading dialog and request otp code`() = runTest {
val initialState = DEFAULT_STATE.copy(
title = BitwardenString.verify_your_account_email_address.asText(),
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText(),
showResendCodeButton = true,
viewState = OTP_CONTENT_VIEW_STATE,
hasMasterPassword = false,
)
coEvery { authRepository.requestOneTimePasscode() } returns RequestOtpResult.Success
createViewModel(state = initialState).also { viewModel ->
@@ -181,14 +366,16 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Test
fun `SendOtpCodeResultReceive success should show snackbar`() = runTest {
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.SendOtpCodeResultReceive(
RequestOtpResult.Success,
),
)
createViewModel(state = DEFAULT_STATE).also { viewModel ->
viewModel.eventFlow.test {
awaitValidateImportRequestEvent()
viewModel.trySendAction(
VerifyPasswordAction.Internal.SendOtpCodeResultReceive(
RequestOtpResult.Success,
),
)
assertEquals(
VerifyPasswordEvent.ShowSnackbar(BitwardenString.code_sent.asText()),
awaitItem(),
@@ -199,7 +386,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Test
fun `SendOtpCodeResultReceive error should show dialog`() = runTest {
createViewModel().also { viewModel ->
createViewModel(state = DEFAULT_STATE).also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.SendOtpCodeResultReceive(
RequestOtpResult.Error(
@@ -221,7 +408,10 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Test
fun `ContinueClick with otp should verify otp`() = runTest {
val initialState = DEFAULT_STATE.copy(showResendCodeButton = true, input = "123456")
val initialState = DEFAULT_STATE.copy(
viewState = OTP_CONTENT_VIEW_STATE.copy(input = "123456"),
hasMasterPassword = false,
)
coEvery {
authRepository.verifyOneTimePasscode("123456")
} returns VerifyOtpResult.Verified
@@ -235,30 +425,33 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Test
fun `VerifyOtpResultReceive verified should send event and clear input`() = runTest {
createViewModel(state = DEFAULT_STATE.copy(input = "123"))
val initialState = DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(input = "123"),
)
createViewModel(state = initialState)
.also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.VerifyOtpResultReceive(
VerifyOtpResult.Verified,
),
)
viewModel.eventFlow.test {
awaitValidateImportRequestEvent()
viewModel.trySendAction(
VerifyPasswordAction.Internal.VerifyOtpResultReceive(
VerifyOtpResult.Verified,
),
)
assertEquals(
VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID),
awaitItem(),
)
}
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
}
@Test
fun `VerifyOtpResultReceive not verified should show dialog`() = runTest {
createViewModel().also { viewModel ->
createViewModel(state = DEFAULT_STATE).also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.VerifyOtpResultReceive(
VerifyOtpResult.NotVerified(
@@ -280,9 +473,12 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Test
fun `NavigateBackClick should send NavigateBack event`() = runTest {
createViewModel().also {
it.trySendAction(VerifyPasswordAction.NavigateBackClick)
it.eventFlow.test {
createViewModel(state = DEFAULT_STATE).also { viewModel ->
viewModel.eventFlow.test {
awaitValidateImportRequestEvent()
viewModel.trySendAction(VerifyPasswordAction.NavigateBackClick)
assertEquals(
VerifyPasswordEvent.NavigateBack,
awaitItem(),
@@ -295,9 +491,12 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
fun `NavigateBackClick should send CancelExport event when hasOtherAccounts is false`() =
runTest {
val initialState = DEFAULT_STATE.copy(hasOtherAccounts = false)
createViewModel(state = initialState).also {
it.trySendAction(VerifyPasswordAction.NavigateBackClick)
it.eventFlow.test {
createViewModel(state = initialState).also { viewModel ->
viewModel.eventFlow.test {
awaitValidateImportRequestEvent()
viewModel.trySendAction(VerifyPasswordAction.NavigateBackClick)
assertEquals(
VerifyPasswordEvent.CancelExport,
awaitItem(),
@@ -308,7 +507,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Test
fun `ContinueClick with empty input should show error dialog`() = runTest {
createViewModel().also {
createViewModel(state = DEFAULT_STATE).also {
it.trySendAction(VerifyPasswordAction.ContinueClick)
it.stateFlow.test {
assertEquals(
@@ -331,11 +530,27 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `ContinueClick should do nothing when the view state is not Content`() = runTest {
createViewModel(state = DEFAULT_LOADING_STATE).also { viewModel ->
viewModel.trySendAction(VerifyPasswordAction.ContinueClick)
assertEquals(DEFAULT_LOADING_STATE, viewModel.stateFlow.value)
coVerify(exactly = 0) {
authRepository.validatePassword(password = any())
authRepository.switchAccount(userId = any())
vaultRepository.unlockVaultWithMasterPassword(masterPassword = any())
}
}
}
@Suppress("MaxLineLength")
@Test
fun `ContinueClick with non-empty input should show loading dialog, validate password and send validates password`() =
runTest {
val initialState = DEFAULT_STATE.copy(input = "mockInput")
val initialState = DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(input = "mockInput"),
)
coEvery { authRepository.validatePassword(password = "mockInput") } just awaits
createViewModel(state = initialState).also { viewModel ->
@@ -367,9 +582,9 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
fun `ContinueClick with non-empty input should show loading dialog, switch accounts, then validate password when selected account is not active and switch is successful`() =
runTest {
val initialState = DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(input = "mockInput"),
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
.copy(userId = "otherUserId"),
input = "mockInput",
)
every {
authRepository.switchAccount("otherUserId")
@@ -400,9 +615,9 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
fun `ContinueClick with non-empty input should show error dialog when switch account is unsuccessful`() =
runTest {
val initialState = DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(input = "mockInput"),
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
.copy(userId = "otherUserId"),
input = "mockInput",
)
every {
authRepository.switchAccount("otherUserId")
@@ -438,7 +653,9 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Test
fun `ContinueClick with non-empty input should show loading dialog, then unlock vault when vault is locked`() =
runTest {
val initialState = DEFAULT_STATE.copy(input = "mockInput")
val initialState = DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(input = "mockInput"),
)
every { vaultRepository.isVaultUnlocked(any()) } returns false
coEvery {
vaultRepository.unlockVaultWithMasterPassword(masterPassword = "mockInput")
@@ -455,7 +672,9 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
awaitItem(),
)
coVerify {
vaultRepository.unlockVaultWithMasterPassword(masterPassword = "mockInput")
vaultRepository.unlockVaultWithMasterPassword(
masterPassword = "mockInput",
)
}
}
}
@@ -468,12 +687,25 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
VerifyPasswordAction.PasswordInputChangeReceive("mockInput"),
)
assertEquals(
DEFAULT_STATE.copy(input = "mockInput"),
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(input = "mockInput"),
),
viewModel.stateFlow.value,
)
}
}
@Test
fun `PasswordInputChangeReceive should do nothing when the view state is not Content`() =
runTest {
createViewModel(state = DEFAULT_LOADING_STATE).also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.PasswordInputChangeReceive("mockInput"),
)
assertEquals(DEFAULT_LOADING_STATE, viewModel.stateFlow.value)
}
}
@Test
fun `DismissDialog should update state`() = runTest {
val initialState = DEFAULT_STATE.copy(
@@ -495,13 +727,16 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Test
fun `ValidatePasswordResultReceive should send PasswordVerified event when result is Success and isValid is true`() =
runTest {
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
ValidatePasswordResult.Success(isValid = true),
),
)
createViewModel(state = DEFAULT_STATE).also { viewModel ->
viewModel.eventFlow.test {
awaitValidateImportRequestEvent()
viewModel.trySendAction(
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
ValidatePasswordResult.Success(isValid = true),
),
)
assertEquals(
VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID),
awaitItem(),
@@ -514,7 +749,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Test
fun `ValidatePasswordResultReceive should show error dialog when result is Success and isValid is false`() =
runTest {
createViewModel().also { viewModel ->
createViewModel(state = DEFAULT_STATE).also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
ValidatePasswordResult.Success(isValid = false),
@@ -535,7 +770,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
fun `ValidatePasswordResultReceive should show error dialog when result is Error`() =
runTest {
val throwable = Throwable()
createViewModel().also { viewModel ->
createViewModel(state = DEFAULT_STATE).also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
ValidatePasswordResult.Error(error = throwable),
@@ -556,13 +791,16 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Test
fun `UnlockVaultResultReceive should send PasswordVerified event when vault unlock result is Success`() =
runTest {
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.Success,
),
)
createViewModel(state = DEFAULT_STATE).also { viewModel ->
viewModel.eventFlow.test {
awaitValidateImportRequestEvent()
viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.Success,
),
)
assertEquals(
VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID),
awaitItem(),
@@ -571,36 +809,12 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is Error`() =
runTest {
val throwable = Throwable()
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.GenericError(error = throwable),
),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
error = throwable,
),
),
viewModel.stateFlow.value,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is AuthenticationError`() =
runTest {
val throwable = Throwable()
createViewModel().also { viewModel ->
createViewModel(state = DEFAULT_STATE).also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.AuthenticationError(error = throwable),
@@ -624,7 +838,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is BiometricDecodingError`() =
runTest {
val throwable = Throwable()
createViewModel().also { viewModel ->
createViewModel(state = DEFAULT_STATE).also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.BiometricDecodingError(error = throwable),
@@ -648,7 +862,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is InvalidStateError`() =
runTest {
val throwable = Throwable()
createViewModel().also { viewModel ->
createViewModel(state = DEFAULT_STATE).also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.InvalidStateError(error = throwable),
@@ -672,7 +886,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is GenericError`() =
runTest {
val throwable = Throwable()
createViewModel().also { viewModel ->
createViewModel(state = DEFAULT_STATE).also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.GenericError(error = throwable),
@@ -692,6 +906,19 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
}
}
/**
* Awaits the [VerifyPasswordEvent.ValidateImportRequest] event that is always emitted when the
* ViewModel is initialized.
*/
private suspend fun TurbineTestContext<VerifyPasswordEvent>.awaitValidateImportRequestEvent() {
assertEquals(
VerifyPasswordEvent.ValidateImportRequest(
importCredentialsRequestData = DEFAULT_IMPORT_REQUEST,
),
awaitItem(),
)
}
private fun createViewModel(
state: VerifyPasswordState? = null,
userId: String = DEFAULT_USER_ID,
@@ -699,13 +926,14 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
authRepository = authRepository,
vaultRepository = vaultRepository,
policyManager = policyManager,
specialCircumstanceManager = specialCircumstanceManager,
savedStateHandle = SavedStateHandle().apply {
set("state", state)
set("userId", userId)
every {
toVerifyPasswordArgs()
} returns VerifyPasswordArgs(
userId = DEFAULT_USER_ID,
userId = userId,
hasOtherAccounts = true,
)
},
@@ -714,6 +942,11 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
private const val DEFAULT_USER_ID: String = "activeUserId"
private const val DEFAULT_ORGANIZATION_ID: String = "activeOrganizationId"
private val DEFAULT_IMPORT_REQUEST = ImportCredentialsRequestData(
uri = mockk<Uri>(),
credentialTypes = setOf("mockCredentialType-1"),
knownExtensions = setOf(),
)
private val DEFAULT_USER_STATE = UserState(
activeUserId = DEFAULT_USER_ID,
accounts = listOf(
@@ -787,11 +1020,28 @@ private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem(
isItemRestricted = false,
initials = DEFAULT_USER_STATE.activeAccount.initials,
)
private val DEFAULT_STATE = VerifyPasswordState(
private val DEFAULT_CONTENT_VIEW_STATE = VerifyPasswordState.ViewState.Content(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
hasOtherAccounts = true,
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
showResendCodeButton = false,
input = "",
)
private val OTP_CONTENT_VIEW_STATE = VerifyPasswordState.ViewState.Content(
title = BitwardenString.verify_your_account_email_address.asText(),
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText(),
showResendCodeButton = true,
input = "",
)
private val DEFAULT_LOADING_STATE = VerifyPasswordState(
importRequest = DEFAULT_IMPORT_REQUEST,
viewState = VerifyPasswordState.ViewState.Loading,
dialog = null,
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
hasOtherAccounts = true,
hasMasterPassword = true,
)
private val DEFAULT_STATE = DEFAULT_LOADING_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE,
)