BIT-1274, BIT-1275, BIT-1276 Add the ability to export user vault data (#1040)

This commit is contained in:
Oleg Semenenko 2024-02-21 13:06:20 -06:00 committed by Álison Fernandes
parent e6883d9599
commit 3fba5d6e9a
20 changed files with 643 additions and 57 deletions

View file

@ -10,6 +10,7 @@ import com.bitwarden.core.Collection
import com.bitwarden.core.CollectionView
import com.bitwarden.core.DateTime
import com.bitwarden.core.DerivePinKeyResponse
import com.bitwarden.core.ExportFormat
import com.bitwarden.core.Folder
import com.bitwarden.core.FolderView
import com.bitwarden.core.InitOrgCryptoRequest
@ -344,4 +345,15 @@ interface VaultSdkSource {
userId: String,
newPassword: String,
): Result<UpdatePasswordResponse>
/**
* Exports the users vault data and returns it as a string in the selected format
* (JSON, CSV, encrypted JSON).
*/
suspend fun exportVaultDataToString(
userId: String,
folders: List<Folder>,
ciphers: List<Cipher>,
format: ExportFormat,
): Result<String>
}

View file

@ -10,6 +10,7 @@ import com.bitwarden.core.Collection
import com.bitwarden.core.CollectionView
import com.bitwarden.core.DateTime
import com.bitwarden.core.DerivePinKeyResponse
import com.bitwarden.core.ExportFormat
import com.bitwarden.core.Folder
import com.bitwarden.core.FolderView
import com.bitwarden.core.InitOrgCryptoRequest
@ -372,6 +373,21 @@ class VaultSdkSourceImpl(
.updatePassword(newPassword)
}
override suspend fun exportVaultDataToString(
userId: String,
folders: List<Folder>,
ciphers: List<Cipher>,
format: ExportFormat,
): Result<String> = runCatching {
getClient(userId = userId)
.exporters()
.exportVault(
folders = folders,
ciphers = ciphers,
format = format,
)
}
private fun getClient(
userId: String,
): Client = sdkClientManager.getOrCreateClient(userId = userId)

View file

@ -28,6 +28,12 @@ interface FileManager {
*/
suspend fun fileToUri(fileUri: Uri, file: File): Boolean
/**
* Writes an [dataString] to a [fileUri]. `true` will be returned if the file was
* successfully saved.
*/
suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean
/**
* Reads the [fileUri] into memory and returns the raw [ByteArray]
*/

View file

@ -96,6 +96,23 @@ class FileManagerImpl(
}
}
override suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean {
@Suppress("TooGenericExceptionCaught")
return try {
withContext(dispatcherManager.io) {
context
.contentResolver
.openOutputStream(fileUri)
?.use { outputStream ->
outputStream.write(dataString.toByteArray())
}
}
true
} catch (exception: RuntimeException) {
false
}
}
override suspend fun uriToByteArray(fileUri: Uri): ByteArray =
withContext(dispatcherManager.io) {
context

View file

@ -4,6 +4,7 @@ import android.net.Uri
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
import com.bitwarden.core.DateTime
import com.bitwarden.core.ExportFormat
import com.bitwarden.core.FolderView
import com.bitwarden.core.SendType
import com.bitwarden.core.SendView
@ -22,6 +23,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
@ -323,4 +325,9 @@ interface VaultRepository : VaultLockManager {
* Attempt to update a folder.
*/
suspend fun updateFolder(folderId: String, folderView: FolderView): UpdateFolderResult
/**
* Attempt to get the user's vault data for export.
*/
suspend fun exportVaultDataToString(format: ExportFormat): ExportVaultDataResult
}

View file

@ -6,6 +6,7 @@ import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
import com.bitwarden.core.DateTime
import com.bitwarden.core.ExportFormat
import com.bitwarden.core.FolderView
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
@ -64,6 +65,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
@ -1215,6 +1217,35 @@ class VaultRepositoryImpl(
}
}
@Suppress("ReturnCount")
override suspend fun exportVaultDataToString(format: ExportFormat): ExportVaultDataResult {
val userId = activeUserId ?: return ExportVaultDataResult.Error
val folders = vaultDiskSource
.getFolders(userId)
.firstOrNull()
.orEmpty()
.map { it.toEncryptedSdkFolder() }
val ciphers = vaultDiskSource
.getCiphers(userId)
.firstOrNull()
.orEmpty()
.map { it.toEncryptedSdkCipher() }
.filter { it.collectionIds.isEmpty() }
return vaultSdkSource
.exportVaultDataToString(
userId = userId,
folders = folders,
ciphers = ciphers,
format = format,
)
.fold(
onSuccess = { ExportVaultDataResult.Success(it) },
onFailure = { ExportVaultDataResult.Error },
)
}
/**
* Checks if the given [userId] has an associated encrypted PIN key but not a pin-protected user
* key. This indicates a scenario in which a user has requested PIN unlocking but requires

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.vault.repository.model
/**
* Models result of the vault data being exported.
*/
sealed class ExportVaultDataResult {
/**
* The vault data has been successfully converted into the selected format
* (JSON, CSV, encrypted JSON).
*/
data class Success(val vaultData: String) : ExportVaultDataResult()
/**
* There was an error converting the vault data.
*/
data object Error : ExportVaultDataResult()
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@ -45,6 +46,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.util.displayLabel
import kotlinx.collections.immutable.toImmutableList
@ -56,10 +59,21 @@ import kotlinx.collections.immutable.toImmutableList
@Composable
fun ExportVaultScreen(
onNavigateBack: () -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
viewModel: ExportVaultViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val exportVaultLocationReceived: (Uri) -> Unit = remember {
{ viewModel.trySendAction(ExportVaultAction.ExportLocationReceive(it)) }
}
val fileSaverLauncher = intentManager.getActivityResultLauncher { activityResult ->
intentManager.getFileDataFromActivityResult(activityResult)?.let {
exportVaultLocationReceived.invoke(it.uri)
}
}
EventsEffect(viewModel = viewModel) { event ->
when (event) {
ExportVaultEvent.NavigateBack -> onNavigateBack()
@ -67,6 +81,14 @@ fun ExportVaultScreen(
is ExportVaultEvent.ShowToast -> {
Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show()
}
is ExportVaultEvent.NavigateToSelectExportDataLocation -> {
fileSaverLauncher.launch(
intentManager.createDocumentIntent(
fileName = event.fileName,
),
)
}
}
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
import android.net.Uri
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
@ -8,16 +9,24 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
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
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.toExportFormat
import com.x8bit.bitwarden.ui.platform.util.fileExtension
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.time.Clock
import javax.inject.Inject
private const val KEY_STATE = "state"
@ -25,15 +34,20 @@ private const val KEY_STATE = "state"
/**
* Manages application state for the Export Vault screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class ExportVaultViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val policyManager: PolicyManager,
savedStateHandle: SavedStateHandle,
private val vaultRepository: VaultRepository,
private val fileManager: FileManager,
private val clock: Clock,
) : BaseViewModel<ExportVaultState, ExportVaultEvent, ExportVaultAction>(
initialState = savedStateHandle[KEY_STATE]
?: ExportVaultState(
dialogState = null,
exportData = null,
exportFormat = ExportVaultFormat.JSON,
passwordInput = "",
policyPreventsExport = policyManager
@ -55,10 +69,19 @@ class ExportVaultViewModel @Inject constructor(
ExportVaultAction.DialogDismiss -> handleDialogDismiss()
is ExportVaultAction.ExportFormatOptionSelect -> handleExportFormatOptionSelect(action)
is ExportVaultAction.PasswordInputChanged -> handlePasswordInputChanged(action)
is ExportVaultAction.ExportLocationReceive -> handleExportLocationReceive(action)
is ExportVaultAction.Internal.ReceiveValidatePasswordResult -> {
handleReceiveValidatePasswordResult(action)
}
is ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult -> {
handleReceivePrepareVaultDataResult(action)
}
is ExportVaultAction.Internal.SaveExportDataToUriResultReceive -> {
handleExportDataFinishedSavingToDisk(action)
}
}
}
@ -75,16 +98,11 @@ class ExportVaultViewModel @Inject constructor(
private fun handleConfirmExportVaultClicked() {
// Display an error alert if the user hasn't entered a password.
if (mutableStateFlow.value.passwordInput.isBlank()) {
mutableStateFlow.update {
it.copy(
dialogState = ExportVaultState.DialogState.Error(
title = null,
message = R.string.validation_field_required.asText(
R.string.master_password.asText(),
),
),
)
}
updateStateWithError(
R.string.validation_field_required.asText(
R.string.master_password.asText(),
),
)
return
}
@ -116,6 +134,27 @@ class ExportVaultViewModel @Inject constructor(
}
}
/**
* Save the vault data in the location.
*/
private fun handleExportLocationReceive(action: ExportVaultAction.ExportLocationReceive) {
val exportData = state.exportData
if (exportData == null) {
updateStateWithError(R.string.export_vault_failure.asText())
return
}
viewModelScope.launch {
val result = fileManager
.stringToUri(
fileUri = action.fileUri,
dataString = exportData,
)
sendAction(ExportVaultAction.Internal.SaveExportDataToUriResultReceive(result))
}
}
/**
* Update the state with the new password input.
*/
@ -133,34 +172,91 @@ class ExportVaultViewModel @Inject constructor(
) {
when (action.result) {
ValidatePasswordResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = ExportVaultState.DialogState.Error(
title = null,
message = R.string.generic_error_message.asText(),
),
)
}
updateStateWithError(R.string.generic_error_message.asText())
}
is ValidatePasswordResult.Success -> {
// Display an error dialog if the password is invalid.
if (!action.result.isValid) {
mutableStateFlow.update {
it.copy(
dialogState = ExportVaultState.DialogState.Error(
title = null,
message = R.string.invalid_master_password.asText(),
),
)
}
} else {
// TODO: BIT-1274, BIT-1275, and BIT-1276
sendEvent(ExportVaultEvent.ShowToast("Not yet implemented".asText()))
updateStateWithError(R.string.invalid_master_password.asText())
return
}
mutableStateFlow.update {
it.copy(dialogState = ExportVaultState.DialogState.Loading())
}
viewModelScope.launch {
val result = vaultRepository.exportVaultDataToString(
format = state.exportFormat.toExportFormat(state.passwordInput),
)
sendAction(
ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult(
result = result,
),
)
}
}
}
}
/**
* Show an error message or proceed to export the vault after receiving the data.
*/
private fun handleReceivePrepareVaultDataResult(
action: ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult,
) {
when (val result = action.result) {
is ExportVaultDataResult.Error -> {
updateStateWithError(
message = R.string.export_vault_failure.asText(),
)
}
is ExportVaultDataResult.Success -> {
val date = clock.instant().toFormattedPattern(
pattern = "yyyyMMddHHmmss",
)
val extension = state.exportFormat.fileExtension
val fileName = "bitwarden_export_$date.$extension"
mutableStateFlow.update {
it.copy(
dialogState = null,
passwordInput = "",
exportData = result.vaultData,
)
}
sendEvent(
ExportVaultEvent.NavigateToSelectExportDataLocation(fileName),
)
}
}
}
private fun handleExportDataFinishedSavingToDisk(
action: ExportVaultAction.Internal.SaveExportDataToUriResultReceive,
) {
if (!action.result) {
updateStateWithError(R.string.export_vault_failure.asText())
return
}
sendEvent(ExportVaultEvent.ShowToast(R.string.export_vault_success.asText()))
}
private fun updateStateWithError(message: Text) {
mutableStateFlow.update {
it.copy(
dialogState = ExportVaultState.DialogState.Error(
title = null,
message = message,
),
)
}
}
}
/**
@ -168,6 +264,8 @@ class ExportVaultViewModel @Inject constructor(
*/
@Parcelize
data class ExportVaultState(
@IgnoredOnParcel
val exportData: String? = null,
val dialogState: DialogState?,
val exportFormat: ExportVaultFormat,
val passwordInput: String,
@ -192,7 +290,7 @@ data class ExportVaultState(
*/
@Parcelize
data class Loading(
val message: Text,
val message: Text = R.string.loading.asText(),
) : DialogState()
}
}
@ -210,6 +308,11 @@ sealed class ExportVaultEvent {
* Shows a toast with the given [message].
*/
data class ShowToast(val message: Text) : ExportVaultEvent()
/**
* Navigates to select a location where to save the vault data with the [fileName].
*/
data class NavigateToSelectExportDataLocation(val fileName: String) : ExportVaultEvent()
}
/**
@ -236,6 +339,13 @@ sealed class ExportVaultAction {
*/
data class ExportFormatOptionSelect(val option: ExportVaultFormat) : ExportVaultAction()
/**
* Indicates the user has selected a location to save the file.
*/
data class ExportLocationReceive(
val fileUri: Uri,
) : ExportVaultAction()
/**
* Indicates that the password input has changed.
*/
@ -245,6 +355,21 @@ sealed class ExportVaultAction {
* Models actions that the [ExportVaultViewModel] might send itself.
*/
sealed class Internal : ExportVaultAction() {
/**
* Indicates that the item has finished saving to disk.
*/
data class SaveExportDataToUriResultReceive(
val result: Boolean,
) : Internal()
/**
* Indicates that the result for exporting the vault data has been received.
*/
data class ReceiveExportVaultDataToStringResult(
val result: ExportVaultDataResult,
) : Internal()
/**
* Indicates that a validate password result has been received.
*/

View file

@ -1,5 +1,7 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model
import com.bitwarden.core.ExportFormat
/**
* Represents the file formats a user can select to export the vault.
*/
@ -8,3 +10,13 @@ enum class ExportVaultFormat {
CSV,
JSON_ENCRYPTED,
}
/**
* Converts the [ExportVaultFormat] to [ExportFormat].
*/
fun ExportVaultFormat.toExportFormat(password: String): ExportFormat =
when (this) {
ExportVaultFormat.JSON -> ExportFormat.Json
ExportVaultFormat.CSV -> ExportFormat.Csv
ExportVaultFormat.JSON_ENCRYPTED -> ExportFormat.EncryptedJson(password)
}

View file

@ -76,9 +76,9 @@ interface IntentManager {
fun createFileChooserIntent(withCameraIntents: Boolean): Intent
/**
* Creates an intent to use when selecting to save an attachment with [fileName] to disk.
* Creates an intent to use when selecting to save an item with [fileName] to disk.
*/
fun createAttachmentChooserIntent(fileName: String): Intent
fun createDocumentIntent(fileName: String): Intent
/**
* Represents file information.

View file

@ -172,7 +172,7 @@ class IntentManagerImpl(
return chooserIntent
}
override fun createAttachmentChooserIntent(fileName: String): Intent =
override fun createDocumentIntent(fileName: String): Intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
// Attempt to get the MIME type from the file extension
val extension = MimeTypeMap.getFileExtensionFromUrl(fileName)

View file

@ -2,9 +2,22 @@ package com.x8bit.bitwarden.ui.platform.util
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
/**
* Provides a human-readable label for the export format.
*/
val ExportVaultFormat.displayLabel: String
get() = when (this) {
ExportVaultFormat.JSON -> ".json"
ExportVaultFormat.CSV -> ".csv"
ExportVaultFormat.JSON_ENCRYPTED -> ".json (Encrypted)"
}
/**
* Provides the file extension associated with the export format.
*/
val ExportVaultFormat.fileExtension: String
get() = when (this) {
ExportVaultFormat.JSON -> "json"
ExportVaultFormat.CSV -> "csv"
ExportVaultFormat.JSON_ENCRYPTED -> "json"
}

View file

@ -114,7 +114,7 @@ fun VaultItemScreen(
is VaultItemEvent.NavigateToSelectAttachmentSaveLocation -> {
fileChooserLauncher.launch(
intentManager.createAttachmentChooserIntent(event.fileName),
intentManager.createDocumentIntent(event.fileName),
)
}
}

View file

@ -10,6 +10,7 @@ import com.bitwarden.core.Collection
import com.bitwarden.core.CollectionView
import com.bitwarden.core.DateTime
import com.bitwarden.core.DerivePinKeyResponse
import com.bitwarden.core.ExportFormat
import com.bitwarden.core.Folder
import com.bitwarden.core.FolderView
import com.bitwarden.core.InitOrgCryptoRequest
@ -24,6 +25,7 @@ import com.bitwarden.sdk.BitwardenException
import com.bitwarden.sdk.Client
import com.bitwarden.sdk.ClientAuth
import com.bitwarden.sdk.ClientCrypto
import com.bitwarden.sdk.ClientExporters
import com.bitwarden.sdk.ClientPasswordHistory
import com.bitwarden.sdk.ClientPlatform
import com.bitwarden.sdk.ClientVault
@ -31,6 +33,8 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkCipher
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFolder
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@ -49,14 +53,18 @@ class VaultSdkSourceTest {
private val clientCrypto = mockk<ClientCrypto>()
private val clientPlatform = mockk<ClientPlatform>()
private val clientPasswordHistory = mockk<ClientPasswordHistory>()
private val clientVault = mockk<ClientVault>() {
private val clientVault = mockk<ClientVault> {
every { passwordHistory() } returns clientPasswordHistory
}
private val client = mockk<Client>() {
private val clientExporters = mockk<ClientExporters> {
coEvery { exportVault(any(), any(), any()) }
}
private val client = mockk<Client> {
every { auth() } returns clientAuth
every { vault() } returns clientVault
every { platform() } returns clientPlatform
every { crypto() } returns clientCrypto
every { exporters() } returns clientExporters
}
private val sdkClientManager = mockk<SdkClientManager> {
every { getOrCreateClient(any()) } returns client
@ -847,4 +855,43 @@ class VaultSdkSourceTest {
result,
)
}
@Test
fun `exportVaultDataToString should call SDK and return a Result with the correct data`() =
runTest {
val userId = "userId"
val expected = "TestResult"
val format = ExportFormat.Json
val ciphers = listOf(createMockSdkCipher(1))
val folders = listOf(createMockSdkFolder(1))
coEvery {
clientExporters.exportVault(
folders = folders,
ciphers = ciphers,
format = format,
)
} returns expected
val result = vaultSdkSource.exportVaultDataToString(
userId = userId,
folders = folders,
ciphers = ciphers,
format = ExportFormat.Json,
)
coVerify {
clientExporters.exportVault(
folders = folders,
ciphers = ciphers,
format = format,
)
}
assertEquals(
expected.asSuccess(),
result,
)
}
}

View file

@ -8,6 +8,7 @@ import com.bitwarden.core.Cipher
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
import com.bitwarden.core.DateTime
import com.bitwarden.core.ExportFormat
import com.bitwarden.core.Folder
import com.bitwarden.core.FolderView
import com.bitwarden.core.InitOrgCryptoRequest
@ -92,6 +93,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
@ -5450,6 +5452,63 @@ class VaultRepositoryTest {
}
}
@Suppress("MaxLineLength")
@Test
fun `exportVaultDataToString should return a success result when data is successfully converted for export`() =
runTest {
val format = ExportFormat.Json
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
coEvery {
vaultDiskSource.getCiphers(userId)
} returns flowOf(listOf(createMockCipher(1)))
coEvery {
vaultDiskSource.getFolders(userId)
} returns flowOf(listOf(createMockFolder(1)))
coEvery {
vaultSdkSource.exportVaultDataToString(userId, any(), any(), format)
} returns "TestResult".asSuccess()
val expected = ExportVaultDataResult.Success(vaultData = "TestResult")
val result = vaultRepository.exportVaultDataToString(format = format)
assertEquals(
expected,
result,
)
}
@Test
fun `exportVaultDataToString should return a failure result when the data conversion fails`() =
runTest {
val format = ExportFormat.Json
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
coEvery {
vaultDiskSource.getCiphers(userId)
} returns flowOf(listOf(createMockCipher(1)))
coEvery {
vaultDiskSource.getFolders(userId)
} returns flowOf(listOf(createMockFolder(1)))
coEvery {
vaultSdkSource.exportVaultDataToString(userId, any(), any(), format)
} returns Throwable("Fail").asFailure()
val expected = ExportVaultDataResult.Error
val result = vaultRepository.exportVaultDataToString(format = format)
assertEquals(
expected,
result,
)
}
//region Helper functions
/**

View file

@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import io.mockk.every
import io.mockk.mockk
@ -36,16 +37,28 @@ class ExportVaultScreenTest : BaseComposeTest() {
every { stateFlow } returns mutableStateFlow
}
private val intentManager = mockk<IntentManager>(relaxed = true)
@Before
fun setUp() {
composeTestRule.setContent {
ExportVaultScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
intentManager = intentManager,
)
}
}
@Test
fun `NavigateToSelectExportDataLocation should invoke createDocumentIntent`() {
mutableEventFlow.tryEmit(ExportVaultEvent.NavigateToSelectExportDataLocation("test.json"))
verify(exactly = 1) {
intentManager.createDocumentIntent("test.json")
}
}
@Test
fun `basicDialog should update according to state`() {
composeTestRule.onNodeWithText("Error message").assertDoesNotExist()
@ -62,6 +75,23 @@ class ExportVaultScreenTest : BaseComposeTest() {
composeTestRule.onNodeWithText("Error message").isDisplayed()
}
@Test
fun `progress dialog should be displayed according to state`() {
val loadingMessage = "loading..."
mutableStateFlow.update {
it.copy(
dialogState = ExportVaultState.DialogState.Loading(loadingMessage.asText()),
)
}
composeTestRule
.onNodeWithText(loadingMessage)
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
mutableStateFlow.update { it.copy(dialogState = null) }
composeTestRule.onNode(isDialog()).assertDoesNotExist()
}
@Test
fun `close button click should send CloseButtonClick action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
@ -199,5 +229,6 @@ private val DEFAULT_STATE = ExportVaultState(
dialogState = null,
exportFormat = ExportVaultFormat.JSON,
passwordInput = "",
exportData = "",
policyPreventsExport = false,
)

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
@ -8,15 +9,25 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
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.createMockPolicy
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.TimeZone
class ExportVaultViewModelTest : BaseViewModelTest() {
private val authRepository: AuthRepository = mockk()
@ -27,7 +38,25 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
} returns emptyList()
}
private val savedStateHandle = SavedStateHandle()
private val clock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val vaultRepository: VaultRepository = mockk {
coEvery { exportVaultDataToString(any()) } returns mockk()
}
private val fileManager: FileManager = mockk()
@BeforeEach
fun setup() {
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
}
@AfterEach
fun teardown() {
TimeZone.setDefault(null)
}
@Test
fun `initial state should be correct`() = runTest {
@ -59,25 +88,26 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
}
@Test
fun `ConfirmExportVaultClicked correct password should emit ShowToast`() = runTest {
val password = "password"
coEvery {
authRepository.validatePassword(
password = password,
)
} returns ValidatePasswordResult.Success(isValid = true)
fun `ConfirmExportVaultClicked correct password should call exportVaultDataToString`() =
runTest {
val password = "password"
coEvery {
authRepository.validatePassword(
password = password,
)
} returns ValidatePasswordResult.Success(isValid = true)
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(password))
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(password))
viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked)
assertEquals(
ExportVaultEvent.ShowToast("Not yet implemented".asText()),
awaitItem(),
)
viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked)
coVerify {
vaultRepository.exportVaultDataToString(any())
}
}
}
}
@Test
fun `ConfirmExportVaultClicked blank password should show an error`() = runTest {
@ -191,11 +221,135 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
}
}
private fun createViewModel(): ExportVaultViewModel =
@Test
fun `ReceiveExportVaultDataToStringResult should update state to error if result is error`() =
runTest {
val viewModel = createViewModel()
viewModel.trySendAction(
ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult(
result = ExportVaultDataResult.Error,
),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = ExportVaultState.DialogState.Error(
title = null,
message = R.string.export_vault_failure.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `ReceiveExportVaultDataToStringResult should emit NavigateToSelectExportDataLocation on result success`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult(
result = ExportVaultDataResult.Success(vaultData = "TestVaultData"),
),
)
assertEquals(
ExportVaultEvent.NavigateToSelectExportDataLocation(
fileName = "bitwarden_export_20231027120000.json",
),
awaitItem(),
)
}
}
@Test
fun `ExportLocationReceive should update state to error if exportData is null`() {
val viewModel = createViewModel()
val uri = mockk<Uri>()
viewModel.trySendAction(ExportVaultAction.ExportLocationReceive(fileUri = uri))
assertEquals(
DEFAULT_STATE.copy(
dialogState = ExportVaultState.DialogState.Error(
title = null,
message = R.string.export_vault_failure.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `ExportLocationReceive should update state to error if saving the data fails`() =
runTest {
val exportData = "TestExportVaultData"
val viewModel = createViewModel(
DEFAULT_STATE.copy(
exportData = exportData,
),
)
val uri = mockk<Uri>()
coEvery {
fileManager.stringToUri(fileUri = any(), dataString = exportData)
} returns false
viewModel.trySendAction(ExportVaultAction.ExportLocationReceive(fileUri = uri))
coVerify {
fileManager.stringToUri(fileUri = any(), dataString = exportData)
}
assertEquals(
DEFAULT_STATE.copy(
exportData = exportData,
dialogState = ExportVaultState.DialogState.Error(
title = null,
message = R.string.export_vault_failure.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `ExportLocationReceive should emit ShowToast on success`() = runTest {
val exportData = "TestExportVaultData"
val viewModel = createViewModel(
DEFAULT_STATE.copy(
exportData = exportData,
),
)
val uri = mockk<Uri>()
coEvery { fileManager.stringToUri(fileUri = any(), dataString = exportData) } returns true
viewModel.eventFlow.test {
viewModel.trySendAction(ExportVaultAction.ExportLocationReceive(uri))
coVerify { fileManager.stringToUri(fileUri = any(), dataString = exportData) }
assertEquals(
ExportVaultEvent.ShowToast(R.string.export_vault_success.asText()),
awaitItem(),
)
}
}
private fun createViewModel(
initialState: ExportVaultState? = null,
): ExportVaultViewModel =
ExportVaultViewModel(
authRepository = authRepository,
policyManager = policyManager,
savedStateHandle = savedStateHandle,
savedStateHandle = SavedStateHandle(
initialState = mapOf("state" to initialState),
),
fileManager = fileManager,
vaultRepository = vaultRepository,
clock = clock,
)
}
@ -203,5 +357,6 @@ private val DEFAULT_STATE = ExportVaultState(
dialogState = null,
exportFormat = ExportVaultFormat.JSON,
passwordInput = "",
exportData = null,
policyPreventsExport = false,
)

View file

@ -19,4 +19,19 @@ class ExportVaultFormatExtensionTest {
)
}
}
@Test
fun `fileExtension should return the correct value for each type`() {
mapOf(
ExportVaultFormat.JSON to "json",
ExportVaultFormat.CSV to "csv",
ExportVaultFormat.JSON_ENCRYPTED to "json",
)
.forEach { (type, label) ->
assertEquals(
label,
type.fileExtension,
)
}
}
}

View file

@ -145,11 +145,11 @@ class VaultItemScreenTest : BaseComposeTest() {
}
@Test
fun `NavigateToSelectAttachmentSaveLocation should invoke createAttachmentChooserIntent`() {
fun `NavigateToSelectAttachmentSaveLocation should invoke createDocumentIntent`() {
mutableEventFlow.tryEmit(VaultItemEvent.NavigateToSelectAttachmentSaveLocation("test.mp4"))
verify(exactly = 1) {
intentManager.createAttachmentChooserIntent("test.mp4")
intentManager.createDocumentIntent("test.mp4")
}
}