diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountViewModel.kt index 4ce82a4fc14..693415c2d4b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountViewModel.kt @@ -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( 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, ) }, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreen.kt index 9781359b5de..08be4d74347 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreen.kt @@ -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(), ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModel.kt index 4543a8d40f0..14f59a67510 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModel.kt @@ -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( 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. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/handlers/VerifyPasswordHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/handlers/VerifyPasswordHandlers.kt index 04cbfdea700..303faa71ac0 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/handlers/VerifyPasswordHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/handlers/VerifyPasswordHandlers.kt @@ -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 = { }, + ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreenTest.kt index d913b7142a6..4295af25887 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordScreenTest.kt @@ -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() + private val credentialExchangeCompletionManager = + mockk { + every { completeCredentialExport(exportResult = any()) } just runs + } + private val credentialExchangeRequestValidator = mockk { + every { validate(importCredentialsRequestData = any()) } returns true + } private val mockStateFlow = MutableStateFlow(DEFAULT_STATE) private val mockEventFlow = bufferedMutableSharedFlow() private val viewModel = mockk { @@ -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() + 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(), + 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, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModelTest.kt index dcfe75c7b34..4c16ee2ffa0 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/verifypassword/VerifyPasswordViewModelTest.kt @@ -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 { + every { + specialCircumstance + } returns SpecialCircumstance.CredentialExchangeExport(data = DEFAULT_IMPORT_REQUEST) + } @BeforeEach fun setUp() { @@ -79,57 +90,45 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() { @Nested inner class State { @Test - fun `initial state should be correct when account has no master password`() = runTest { - mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( - accounts = DEFAULT_USER_STATE.accounts.map { - it.copy(hasMasterPassword = false) - }, - ) - coEvery { authRepository.requestOneTimePasscode() } returns RequestOtpResult.Success - + fun `initial state should be correct when account has a master password`() = runTest { 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 = true, ), it.stateFlow.value, ) - coVerify { authRepository.requestOneTimePasscode() } + coVerify(exactly = 0) { authRepository.requestOneTimePasscode() } } } @Test - fun `initial state should be correct when account is not restricted`() = runTest { + fun `initial state should be correct 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 { assertEquals( VerifyPasswordState( - title = BitwardenString.verify_your_master_password.asText(), - subtext = null, + importRequest = DEFAULT_IMPORT_REQUEST, + viewState = VerifyPasswordState.ViewState.Loading, + dialog = null, + accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM, 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, - ), + hasMasterPassword = false, ), 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 { createViewModel() } + } + + @Test + fun `initial state should throw when the account cannot be found`() { + assertThrows { 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.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(), + 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, )