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.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(),
)

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.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()
}
}

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
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,
)

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.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,
)