mirror of
https://github.com/bitwarden/android.git
synced 2026-11-09 10:34:18 -06:00
VULN-988: bug: Additional GMS validation for CXP export (#7367)
This commit is contained in:
+7
-5
@@ -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,
|
||||
)
|
||||
},
|
||||
|
||||
+93
-74
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+211
-111
@@ -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.
|
||||
*/
|
||||
|
||||
+13
@@ -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 = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+147
-12
@@ -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,
|
||||
)
|
||||
|
||||
+376
-126
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user