BIT-2235: Add support for exporting vault data w/o passcode (#1281)

This commit is contained in:
Caleb Derosier 2024-04-18 13:03:57 -06:00 committed by Álison Fernandes
parent 9648f720be
commit dae98111e6
4 changed files with 281 additions and 55 deletions

View file

@ -26,10 +26,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -177,6 +178,9 @@ fun ExportVaultScreen(
onPasswordInputChanged = remember(viewModel) { onPasswordInputChanged = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(it)) } { viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(it)) }
}, },
onSendCodeClicked = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.SendCodeClick) }
},
onExportVaultClick = { shouldShowConfirmationDialog = true }, onExportVaultClick = { shouldShowConfirmationDialog = true },
modifier = Modifier modifier = Modifier
.padding(innerPadding) .padding(innerPadding)
@ -193,6 +197,7 @@ private fun ExportVaultScreenContent(
onExportFormatOptionSelected: (ExportVaultFormat) -> Unit, onExportFormatOptionSelected: (ExportVaultFormat) -> Unit,
onFilePasswordInputChanged: (String) -> Unit, onFilePasswordInputChanged: (String) -> Unit,
onPasswordInputChanged: (String) -> Unit, onPasswordInputChanged: (String) -> Unit,
onSendCodeClicked: () -> Unit,
onExportVaultClick: () -> Unit, onExportVaultClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -207,7 +212,7 @@ private fun ExportVaultScreenContent(
BitwardenPolicyWarningText( BitwardenPolicyWarningText(
text = stringResource(id = R.string.disable_personal_vault_export_policy_in_effect), text = stringResource(id = R.string.disable_personal_vault_export_policy_in_effect),
modifier = Modifier modifier = Modifier
.semantics { testTag = "DisablePrivateVaultPolicyLabel" } .testTag("DisablePrivateVaultPolicyLabel")
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.fillMaxWidth(), .fillMaxWidth(),
) )
@ -228,7 +233,7 @@ private fun ExportVaultScreenContent(
}, },
isEnabled = !state.policyPreventsExport, isEnabled = !state.policyPreventsExport,
modifier = Modifier modifier = Modifier
.semantics { testTag = "FileFormatPicker" } .testTag("FileFormatPicker")
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.fillMaxWidth(), .fillMaxWidth(),
) )
@ -245,7 +250,7 @@ private fun ExportVaultScreenContent(
showPasswordChange = { showPassword = it }, showPasswordChange = { showPassword = it },
hint = stringResource(id = R.string.password_used_to_export), hint = stringResource(id = R.string.password_used_to_export),
modifier = Modifier modifier = Modifier
.semantics { testTag = "FilePasswordEntry" } .testTag("FilePasswordEntry")
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.fillMaxWidth(), .fillMaxWidth(),
) )
@ -264,44 +269,87 @@ private fun ExportVaultScreenContent(
showPassword = showPassword, showPassword = showPassword,
showPasswordChange = { showPassword = it }, showPasswordChange = { showPassword = it },
modifier = Modifier modifier = Modifier
.semantics { testTag = "ConfirmFilePasswordEntry" } .testTag("ConfirmFilePasswordEntry")
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.fillMaxWidth(), .fillMaxWidth(),
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
BitwardenPasswordField( if (state.showSendCodeButton) {
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(),
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = stringResource(id = R.string.export_vault_master_password_description), text = stringResource(id = R.string.send_verification_code_to_email),
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.fillMaxWidth(), .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( BitwardenFilledTonalButton(
label = stringResource(id = R.string.export_vault), label = stringResource(id = R.string.export_vault),
onClick = onExportVaultClick, onClick = onExportVaultClick,
isEnabled = !state.policyPreventsExport, isEnabled = !state.policyPreventsExport,
modifier = Modifier modifier = Modifier
.semantics { testTag = "ExportVaultButton" } .testTag("ExportVaultButton")
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.fillMaxWidth(), .fillMaxWidth(),
) )

View file

@ -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.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult 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.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.PolicyManager
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.manager.FileManager
@ -42,7 +43,7 @@ private const val KEY_STATE = "state"
@HiltViewModel @HiltViewModel
class ExportVaultViewModel @Inject constructor( class ExportVaultViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val policyManager: PolicyManager, policyManager: PolicyManager,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val vaultRepository: VaultRepository, private val vaultRepository: VaultRepository,
private val fileManager: FileManager, private val fileManager: FileManager,
@ -61,6 +62,12 @@ class ExportVaultViewModel @Inject constructor(
policyPreventsExport = policyManager policyPreventsExport = policyManager
.getActivePolicies(type = PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT) .getActivePolicies(type = PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT)
.any(), .any(),
showSendCodeButton = authRepository
.userStateFlow
.value
?.activeAccount
?.trustedDevice
?.hasMasterPassword == false,
), ),
) { ) {
/** /**
@ -88,6 +95,7 @@ class ExportVaultViewModel @Inject constructor(
is ExportVaultAction.FilePasswordInputChange -> handleFilePasswordInputChanged(action) is ExportVaultAction.FilePasswordInputChange -> handleFilePasswordInputChanged(action)
is ExportVaultAction.ExportFormatOptionSelect -> handleExportFormatOptionSelect(action) is ExportVaultAction.ExportFormatOptionSelect -> handleExportFormatOptionSelect(action)
is ExportVaultAction.PasswordInputChanged -> handlePasswordInputChanged(action) is ExportVaultAction.PasswordInputChanged -> handlePasswordInputChanged(action)
ExportVaultAction.SendCodeClick -> handleSendCodeClick()
is ExportVaultAction.ExportLocationReceive -> handleExportLocationReceive(action) is ExportVaultAction.ExportLocationReceive -> handleExportLocationReceive(action)
is ExportVaultAction.Internal.ReceiveValidatePasswordResult -> { is ExportVaultAction.Internal.ReceiveValidatePasswordResult -> {
@ -105,6 +113,10 @@ class ExportVaultViewModel @Inject constructor(
is ExportVaultAction.Internal.SaveExportDataToUriResultReceive -> { is ExportVaultAction.Internal.SaveExportDataToUriResultReceive -> {
handleExportDataFinishedSavingToDisk(action) handleExportDataFinishedSavingToDisk(action)
} }
is ExportVaultAction.Internal.ReceiveVerifyOneTimePasscodeResult -> {
handleReceiveVerifyOneTimePasscodeResult(action)
}
} }
} }
@ -158,11 +170,19 @@ class ExportVaultViewModel @Inject constructor(
// Otherwise, validate the password. // Otherwise, validate the password.
viewModelScope.launch { viewModelScope.launch {
sendAction( sendAction(
ExportVaultAction.Internal.ReceiveValidatePasswordResult( if (state.showSendCodeButton) {
result = authRepository.validatePassword( ExportVaultAction.Internal.ReceiveVerifyOneTimePasscodeResult(
password = state.passwordInput, 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. * Show an alert or proceed to export the vault after validating the password.
*/ */
@ -266,27 +292,7 @@ class ExportVaultViewModel @Inject constructor(
return return
} }
mutableStateFlow.update { exportVaultData()
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,
),
)
}
} }
} }
} }
@ -366,6 +372,45 @@ class ExportVaultViewModel @Inject constructor(
sendEvent(ExportVaultEvent.ShowToast(R.string.export_vault_success.asText())) 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) { private fun updateStateWithError(message: Text) {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
@ -393,6 +438,7 @@ data class ExportVaultState(
val passwordInput: String, val passwordInput: String,
val passwordStrengthState: PasswordStrengthState, val passwordStrengthState: PasswordStrengthState,
val policyPreventsExport: Boolean, val policyPreventsExport: Boolean,
val showSendCodeButton: Boolean,
) : Parcelable { ) : Parcelable {
/** /**
* Represents the current state of any dialogs on the screen. * Represents the current state of any dialogs on the screen.
@ -484,6 +530,11 @@ sealed class ExportVaultAction {
*/ */
data class PasswordInputChanged(val input: String) : 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. * Models actions that the [ExportVaultViewModel] might send itself.
*/ */
@ -516,5 +567,12 @@ sealed class ExportVaultAction {
data class ReceiveValidatePasswordResult( data class ReceiveValidatePasswordResult(
val result: ValidatePasswordResult, val result: ValidatePasswordResult,
) : Internal() ) : Internal()
/**
* Indicates that a result for verifying the one-time passcode has been received.
*/
data class ReceiveVerifyOneTimePasscodeResult(
val result: VerifyOtpResult,
) : Internal()
} }
} }

View file

@ -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 @Test
fun `password input change should send PasswordInputChange action`() { fun `password input change should send PasswordInputChange action`() {
val input = "Test123" val input = "Test123"
composeTestRule.onNodeWithText("Master password").performTextInput(input) composeTestRule.onNodeWithText("Master password").performTextInput(input)
verify { verify {
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged("Test123")) viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(input))
} }
} }
} }
@ -267,4 +310,5 @@ private val DEFAULT_STATE = ExportVaultState(
exportData = "", exportData = "",
passwordStrengthState = PasswordStrengthState.NONE, passwordStrengthState = PasswordStrengthState.NONE,
policyPreventsExport = false, policyPreventsExport = false,
showSendCodeButton = false,
) )

View file

@ -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.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.repository.AuthRepository 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.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.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult 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.PolicyManager
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson 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") @Suppress("MaxLineLength")
@Test @Test
fun `ConfirmExportVaultClicked should show success with valid input when export type is JSON_ENCRYPTED`() { 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 @Test
fun `ReceiveExportVaultDataToStringResult should update state to error if result is error`() { fun `ReceiveExportVaultDataToStringResult should update state to error if result is error`() {
val viewModel = createViewModel() val viewModel = createViewModel()
@ -659,4 +734,5 @@ private val DEFAULT_STATE = ExportVaultState(
exportData = null, exportData = null,
passwordStrengthState = PasswordStrengthState.NONE, passwordStrengthState = PasswordStrengthState.NONE,
policyPreventsExport = false, policyPreventsExport = false,
showSendCodeButton = false,
) )