From dae98111e6f7d1a80f87d30cd719fb19ce4dd1dd Mon Sep 17 00:00:00 2001 From: Caleb Derosier <125901828+caleb-livefront@users.noreply.github.com> Date: Thu, 18 Apr 2024 13:03:57 -0600 Subject: [PATCH] BIT-2235: Add support for exporting vault data w/o passcode (#1281) --- .../settings/exportvault/ExportVaultScreen.kt | 102 +++++++++++----- .../exportvault/ExportVaultViewModel.kt | 112 +++++++++++++----- .../exportvault/ExportVaultScreenTest.kt | 46 ++++++- .../exportvault/ExportVaultViewModelTest.kt | 76 ++++++++++++ 4 files changed, 281 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt index 74d701cf2..bdb32a7b3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt @@ -26,10 +26,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -177,6 +178,9 @@ fun ExportVaultScreen( onPasswordInputChanged = remember(viewModel) { { viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(it)) } }, + onSendCodeClicked = remember(viewModel) { + { viewModel.trySendAction(ExportVaultAction.SendCodeClick) } + }, onExportVaultClick = { shouldShowConfirmationDialog = true }, modifier = Modifier .padding(innerPadding) @@ -193,6 +197,7 @@ private fun ExportVaultScreenContent( onExportFormatOptionSelected: (ExportVaultFormat) -> Unit, onFilePasswordInputChanged: (String) -> Unit, onPasswordInputChanged: (String) -> Unit, + onSendCodeClicked: () -> Unit, onExportVaultClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -207,7 +212,7 @@ private fun ExportVaultScreenContent( BitwardenPolicyWarningText( text = stringResource(id = R.string.disable_personal_vault_export_policy_in_effect), modifier = Modifier - .semantics { testTag = "DisablePrivateVaultPolicyLabel" } + .testTag("DisablePrivateVaultPolicyLabel") .padding(horizontal = 16.dp) .fillMaxWidth(), ) @@ -228,7 +233,7 @@ private fun ExportVaultScreenContent( }, isEnabled = !state.policyPreventsExport, modifier = Modifier - .semantics { testTag = "FileFormatPicker" } + .testTag("FileFormatPicker") .padding(horizontal = 16.dp) .fillMaxWidth(), ) @@ -245,7 +250,7 @@ private fun ExportVaultScreenContent( showPasswordChange = { showPassword = it }, hint = stringResource(id = R.string.password_used_to_export), modifier = Modifier - .semantics { testTag = "FilePasswordEntry" } + .testTag("FilePasswordEntry") .padding(horizontal = 16.dp) .fillMaxWidth(), ) @@ -264,44 +269,87 @@ private fun ExportVaultScreenContent( showPassword = showPassword, showPasswordChange = { showPassword = it }, modifier = Modifier - .semantics { testTag = "ConfirmFilePasswordEntry" } + .testTag("ConfirmFilePasswordEntry") .padding(horizontal = 16.dp) .fillMaxWidth(), ) Spacer(modifier = Modifier.height(16.dp)) } - BitwardenPasswordField( - label = stringResource(id = R.string.master_password), - value = state.passwordInput, - readOnly = state.policyPreventsExport, - onValueChange = onPasswordInputChanged, - modifier = Modifier - .semantics { testTag = "MasterPasswordEntry" } - .padding(horizontal = 16.dp) - .fillMaxWidth(), - ) + if (state.showSendCodeButton) { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(id = R.string.export_vault_master_password_description), - textAlign = TextAlign.Start, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - ) + Text( + text = stringResource(id = R.string.send_verification_code_to_email), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) + + BitwardenFilledTonalButton( + label = stringResource(R.string.send_code), + onClick = onSendCodeClicked, + isEnabled = !state.policyPreventsExport, + modifier = Modifier + .testTag("SendTOTPCodeButton") + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + BitwardenPasswordField( + label = stringResource(id = R.string.verification_code), + value = state.passwordInput, + readOnly = state.policyPreventsExport, + hint = stringResource(id = R.string.confirm_your_identity), + onValueChange = onPasswordInputChanged, + keyboardType = KeyboardType.Number, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + } else { + BitwardenPasswordField( + label = stringResource(id = R.string.master_password), + value = state.passwordInput, + readOnly = state.policyPreventsExport, + onValueChange = onPasswordInputChanged, + modifier = Modifier + .testTag("MasterPasswordEntry") + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(id = R.string.export_vault_master_password_description), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + } BitwardenFilledTonalButton( label = stringResource(id = R.string.export_vault), onClick = onExportVaultClick, isEnabled = !state.policyPreventsExport, modifier = Modifier - .semantics { testTag = "ExportVaultButton" } + .testTag("ExportVaultButton") .padding(horizontal = 16.dp) .fillMaxWidth(), ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt index 0ac3ce19d..09759f853 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt @@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult 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.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.manager.FileManager @@ -42,7 +43,7 @@ private const val KEY_STATE = "state" @HiltViewModel class ExportVaultViewModel @Inject constructor( private val authRepository: AuthRepository, - private val policyManager: PolicyManager, + policyManager: PolicyManager, savedStateHandle: SavedStateHandle, private val vaultRepository: VaultRepository, private val fileManager: FileManager, @@ -61,6 +62,12 @@ class ExportVaultViewModel @Inject constructor( policyPreventsExport = policyManager .getActivePolicies(type = PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT) .any(), + showSendCodeButton = authRepository + .userStateFlow + .value + ?.activeAccount + ?.trustedDevice + ?.hasMasterPassword == false, ), ) { /** @@ -88,6 +95,7 @@ class ExportVaultViewModel @Inject constructor( is ExportVaultAction.FilePasswordInputChange -> handleFilePasswordInputChanged(action) is ExportVaultAction.ExportFormatOptionSelect -> handleExportFormatOptionSelect(action) is ExportVaultAction.PasswordInputChanged -> handlePasswordInputChanged(action) + ExportVaultAction.SendCodeClick -> handleSendCodeClick() is ExportVaultAction.ExportLocationReceive -> handleExportLocationReceive(action) is ExportVaultAction.Internal.ReceiveValidatePasswordResult -> { @@ -105,6 +113,10 @@ class ExportVaultViewModel @Inject constructor( is ExportVaultAction.Internal.SaveExportDataToUriResultReceive -> { handleExportDataFinishedSavingToDisk(action) } + + is ExportVaultAction.Internal.ReceiveVerifyOneTimePasscodeResult -> { + handleReceiveVerifyOneTimePasscodeResult(action) + } } } @@ -158,11 +170,19 @@ class ExportVaultViewModel @Inject constructor( // Otherwise, validate the password. viewModelScope.launch { sendAction( - ExportVaultAction.Internal.ReceiveValidatePasswordResult( - result = authRepository.validatePassword( - password = state.passwordInput, - ), - ), + if (state.showSendCodeButton) { + ExportVaultAction.Internal.ReceiveVerifyOneTimePasscodeResult( + result = authRepository.verifyOneTimePasscode( + oneTimePasscode = state.passwordInput, + ), + ) + } else { + ExportVaultAction.Internal.ReceiveValidatePasswordResult( + result = authRepository.validatePassword( + password = state.passwordInput, + ), + ) + }, ) } } @@ -248,6 +268,12 @@ class ExportVaultViewModel @Inject constructor( } } + private fun handleSendCodeClick() { + viewModelScope.launch { + authRepository.requestOneTimePasscode() + } + } + /** * Show an alert or proceed to export the vault after validating the password. */ @@ -266,27 +292,7 @@ class ExportVaultViewModel @Inject constructor( return } - mutableStateFlow.update { - it.copy(dialogState = ExportVaultState.DialogState.Loading()) - } - - viewModelScope.launch { - val result = vaultRepository.exportVaultDataToString( - format = state.exportFormat.toExportFormat( - password = if (state.exportFormat == ExportVaultFormat.JSON_ENCRYPTED) { - state.filePasswordInput - } else { - state.passwordInput - }, - ), - ) - - sendAction( - ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult( - result = result, - ), - ) - } + exportVaultData() } } } @@ -366,6 +372,45 @@ class ExportVaultViewModel @Inject constructor( sendEvent(ExportVaultEvent.ShowToast(R.string.export_vault_success.asText())) } + private fun handleReceiveVerifyOneTimePasscodeResult( + action: ExportVaultAction.Internal.ReceiveVerifyOneTimePasscodeResult, + ) { + when (action.result) { + VerifyOtpResult.Verified -> exportVaultData() + + is VerifyOtpResult.NotVerified -> { + updateStateWithError(R.string.generic_error_message.asText()) + } + } + } + + /** + * Handles exporting the vault data after all validation has finished. + */ + private fun exportVaultData() { + mutableStateFlow.update { + it.copy(dialogState = ExportVaultState.DialogState.Loading()) + } + + viewModelScope.launch { + val result = vaultRepository.exportVaultDataToString( + format = state.exportFormat.toExportFormat( + password = if (state.exportFormat == ExportVaultFormat.JSON_ENCRYPTED) { + state.filePasswordInput + } else { + state.passwordInput + }, + ), + ) + + sendAction( + ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult( + result = result, + ), + ) + } + } + private fun updateStateWithError(message: Text) { mutableStateFlow.update { it.copy( @@ -393,6 +438,7 @@ data class ExportVaultState( val passwordInput: String, val passwordStrengthState: PasswordStrengthState, val policyPreventsExport: Boolean, + val showSendCodeButton: Boolean, ) : Parcelable { /** * Represents the current state of any dialogs on the screen. @@ -484,6 +530,11 @@ sealed class ExportVaultAction { */ data class PasswordInputChanged(val input: String) : ExportVaultAction() + /** + * Indicates that the user pressed the button to send a code in place of entering a password. + */ + data object SendCodeClick : ExportVaultAction() + /** * Models actions that the [ExportVaultViewModel] might send itself. */ @@ -516,5 +567,12 @@ sealed class ExportVaultAction { data class ReceiveValidatePasswordResult( val result: ValidatePasswordResult, ) : Internal() + + /** + * Indicates that a result for verifying the one-time passcode has been received. + */ + data class ReceiveVerifyOneTimePasscodeResult( + val result: VerifyOtpResult, + ) : Internal() } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt index 17af3dc75..585d2e165 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt @@ -247,12 +247,55 @@ class ExportVaultScreenTest : BaseComposeTest() { } } + @Test + fun `send code click should send SendCodeClick action`() { + mutableStateFlow.update { + it.copy(showSendCodeButton = true) + } + composeTestRule.onNodeWithText("Send code").performClick() + verify { + viewModel.trySendAction(ExportVaultAction.SendCodeClick) + } + } + + @Test + fun `verification code input change should send PasswordInputChange action`() { + val input = "123" + mutableStateFlow.update { + it.copy(showSendCodeButton = true) + } + composeTestRule.onNodeWithText("Verification code").performTextInput(input) + verify { + viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(input)) + } + } + + @Test + fun `send code button and verification input field should appear based on state`() { + composeTestRule.onNodeWithText("Send code").assertIsNotDisplayed() + composeTestRule.onNodeWithText("Verification code").assertIsNotDisplayed() + mutableStateFlow.update { + it.copy(showSendCodeButton = true) + } + composeTestRule.onNodeWithText("Send code").assertIsDisplayed() + composeTestRule.onNodeWithText("Verification code").assertIsDisplayed() + } + + @Test + fun `master password field should appear based on state`() { + composeTestRule.onNodeWithText("Master password").assertIsDisplayed() + mutableStateFlow.update { + it.copy(showSendCodeButton = true) + } + composeTestRule.onNodeWithText("Master password").assertIsNotDisplayed() + } + @Test fun `password input change should send PasswordInputChange action`() { val input = "Test123" composeTestRule.onNodeWithText("Master password").performTextInput(input) verify { - viewModel.trySendAction(ExportVaultAction.PasswordInputChanged("Test123")) + viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(input)) } } } @@ -267,4 +310,5 @@ private val DEFAULT_STATE = ExportVaultState( exportData = "", passwordStrengthState = PasswordStrengthState.NONE, policyPreventsExport = false, + showSendCodeButton = false, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt index 6bca1629f..444e04a8d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt @@ -8,8 +8,10 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult +import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult import com.x8bit.bitwarden.data.auth.repository.model.UserState 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.repository.model.Environment import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson @@ -105,6 +107,66 @@ class ExportVaultViewModelTest : BaseViewModelTest() { } } + @Test + fun `ConfirmExportVaultClicked with verified code should call exportVaultDataToString`() { + val passcode = "1234" + val initialState = DEFAULT_STATE.copy( + passwordInput = passcode, + showSendCodeButton = true, + ) + coEvery { + authRepository.verifyOneTimePasscode( + oneTimePasscode = passcode, + ) + } returns VerifyOtpResult.Verified + + val viewModel = createViewModel(initialState) + + viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked) + + assertEquals( + initialState.copy( + exportData = "data", + passwordInput = "", + ), + viewModel.stateFlow.value, + ) + coVerify { + vaultRepository.exportVaultDataToString(any()) + } + } + + @Test + fun `ConfirmExportVaultClicked with invalid code should call exportVaultDataToString`() { + val passcode = "1234" + val initialState = DEFAULT_STATE.copy( + passwordInput = passcode, + showSendCodeButton = true, + ) + coEvery { + authRepository.verifyOneTimePasscode( + oneTimePasscode = passcode, + ) + } returns VerifyOtpResult.NotVerified("Wrong") + + val viewModel = createViewModel(initialState) + + viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked) + + assertEquals( + initialState.copy( + dialogState = ExportVaultState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + coVerify(exactly = 0) { + vaultRepository.exportVaultDataToString(any()) + } + } + @Suppress("MaxLineLength") @Test fun `ConfirmExportVaultClicked should show success with valid input when export type is JSON_ENCRYPTED`() { @@ -415,6 +477,19 @@ class ExportVaultViewModelTest : BaseViewModelTest() { ) } + @Test + fun `SendCodeClick should call requestOneTimePasscode`() { + val viewModel = createViewModel() + coEvery { authRepository.requestOneTimePasscode() } returns RequestOtpResult.Success + viewModel.trySendAction(ExportVaultAction.SendCodeClick) + + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + coVerify { authRepository.requestOneTimePasscode() } + } + @Test fun `ReceiveExportVaultDataToStringResult should update state to error if result is error`() { val viewModel = createViewModel() @@ -659,4 +734,5 @@ private val DEFAULT_STATE = ExportVaultState( exportData = null, passwordStrengthState = PasswordStrengthState.NONE, policyPreventsExport = false, + showSendCodeButton = false, )