diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt index 8936480d16..c2e794900d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt @@ -24,6 +24,14 @@ interface VaultDiskSource { */ suspend fun getCiphers(userId: String): List + /** + * Retrieves all ciphers with the given [cipherIds] from the data source for a given [userId]. + */ + suspend fun getSelectedCiphers( + userId: String, + cipherIds: List, + ): List + /** * Retrieves all ciphers from the data source for a given [userId] that contain TOTP codes. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt index 09be0d36ae..db8778c31d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt @@ -97,6 +97,24 @@ class VaultDiskSourceImpl( } } + override suspend fun getSelectedCiphers( + userId: String, + cipherIds: List, + ): List { + val entities = ciphersDao.getSelectedCiphers(userId = userId, cipherIds = cipherIds) + return withContext(context = dispatcherManager.default) { + entities + .map { entity -> + async { + json.decodeFromStringWithErrorCallback( + string = entity.cipherJson, + ) { Timber.e(it, "Failed to deserialize Cipher in Vault") } + } + } + .awaitAll() + } + } + override suspend fun getTotpCiphers(userId: String): List { val entities = ciphersDao.getAllTotpCiphers(userId = userId) return withContext(context = dispatcherManager.default) { diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CiphersDao.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CiphersDao.kt index 8e4b21ac7d..4e5e7119ed 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CiphersDao.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CiphersDao.kt @@ -37,6 +37,15 @@ interface CiphersDao { userId: String, ): List + /** + * Retrieves all ciphers from the database with the given [cipherIds] for a given [userId]. + */ + @Query("SELECT * FROM ciphers WHERE user_id = :userId AND id IN (:cipherIds)") + suspend fun getSelectedCiphers( + userId: String, + cipherIds: List, + ): List + /** * Retrieves all ciphers from the database for a given [userId]. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index 6b916bee65..278f049919 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -266,6 +266,13 @@ interface VaultRepository : CipherManager, VaultLockManager { */ suspend fun importCxfPayload(payload: String): ImportCxfPayloadResult + /** + * Attempt to export the vault data to a CXF file. + * + * @param ciphers Ciphers selected for export. + */ + suspend fun exportVaultDataToCxf(ciphers: List): Result + /** * Flow that represents the data for a specific vault list item as found by ID. This may emit * `null` if the item cannot be found. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 2363eea097..7a7b478ff8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -94,6 +94,7 @@ import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSend import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSendList +import com.x8bit.bitwarden.data.vault.repository.util.toSdkAccount import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList import kotlinx.coroutines.CancellationException @@ -986,6 +987,28 @@ class VaultRepositoryImpl( ) } + override suspend fun exportVaultDataToCxf( + ciphers: List, + ): Result { + val userId = activeUserId + ?: return NoActiveUserException().asFailure() + val account = authDiskSource.userState + ?.activeAccount + ?.toSdkAccount() + ?: return NoActiveUserException().asFailure() + + val ciphers = vaultDiskSource + .getSelectedCiphers(userId = userId, cipherIds = ciphers.mapNotNull { it.id }) + .map { it.toEncryptedSdkCipher() } + + return vaultSdkSource + .exportVaultDataToCxf( + userId = userId, + account = account, + ciphers = ciphers, + ) + } + /** * 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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/AccountJsonExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/AccountJsonExtensions.kt new file mode 100644 index 0000000000..23003dbe44 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/AccountJsonExtensions.kt @@ -0,0 +1,13 @@ +package com.x8bit.bitwarden.data.vault.repository.util + +import com.bitwarden.exporters.Account +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson + +/** + * Converts a [AccountJson] to a [Account] for use in the SDK. + */ +fun AccountJson.toSdkAccount(): Account = Account( + id = profile.userId, + email = profile.email, + name = profile.name, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt index 02e80d18b6..1b05b81791 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt @@ -105,6 +105,22 @@ class VaultDiskSourceTest { assertEquals(ciphers, result2) } + @Test + fun `getSelectedCiphers should return selected CiphersDao ciphers`() = runTest { + val cipherEntities = listOf( + CIPHER_ENTITY, + CIPHER_ENTITY.copy(id = "otherCipherId"), + ) + val ciphers = listOf(CIPHER_1) + val cipherIds = listOf("mockId-1") + + val result1 = vaultDiskSource.getSelectedCiphers(USER_ID, cipherIds) + assertEquals(emptyList(), result1) + ciphersDao.insertCiphers(cipherEntities) + val result2 = vaultDiskSource.getSelectedCiphers(USER_ID, cipherIds) + assertEquals(ciphers, result2) + } + @Test fun `getTotpCiphers should return all CiphersDao totp ciphers`() = runTest { val cipherEntities = listOf( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCiphersDao.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCiphersDao.kt index d5216a22ff..07948be445 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCiphersDao.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCiphersDao.kt @@ -41,6 +41,12 @@ class FakeCiphersDao : CiphersDao { override suspend fun getAllCiphers(userId: String): List = storedCiphers.filter { it.userId == userId } + override suspend fun getSelectedCiphers( + userId: String, + cipherIds: List, + ): List = + storedCiphers.filter { it.userId == userId && it.id in cipherIds } + override suspend fun getAllTotpCiphers(userId: String): List = storedCiphers.filter { it.userId == userId && it.hasTotp } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/AccountUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/AccountUtil.kt new file mode 100644 index 0000000000..45b8c2ee03 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/AccountUtil.kt @@ -0,0 +1,16 @@ +package com.x8bit.bitwarden.data.vault.datasource.sdk.model + +import com.bitwarden.exporters.Account + +/** + * Creates a mock [com.bitwarden.exporters.Account] for testing purposes + */ +fun createMockAccount( + number: Int, + email: String = "mockEmail-$number", + name: String? = "mockName-$number", +): Account = Account( + id = "mockId-$number", + email = email, + name = name, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index 275c47d0da..06cb112468 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -71,6 +71,8 @@ import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockAccount +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherListView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockDecryptCipherListResult @@ -4298,6 +4300,62 @@ class VaultRepositoryTest { ) } + @Test + fun `exportVaultDataToCxf should return success result`() = runTest { + val userId = "mockId-1" + val account = createMockAccount(number = 1, email = "email", name = null) + val cipherListViews = listOf(createMockCipherListView(number = 1)) + + fakeAuthDiskSource.userState = MOCK_USER_STATE + + coEvery { + vaultSdkSource.exportVaultDataToCxf( + userId = userId, + account = account, + ciphers = any(), + ) + } returns "TestResult".asSuccess() + coEvery { + vaultDiskSource.getSelectedCiphers( + userId = userId, + cipherIds = cipherListViews.mapNotNull { it.id }, + ) + } returns listOf(createMockCipher(number = 1)) + + val result = vaultRepository.exportVaultDataToCxf(ciphers = cipherListViews) + + assertEquals("TestResult".asSuccess(), result) + } + + @Test + fun `exportVaultDataToCxf should return error result when exportVaultDataToCxf fails`() = + runTest { + val userId = "mockId-1" + val account = createMockAccount(number = 1, email = "email", name = null) + val cipherListViews = listOf(createMockCipherListView(number = 1)) + val throwable = Throwable() + fakeAuthDiskSource.userState = MOCK_USER_STATE + + coEvery { + vaultSdkSource.exportVaultDataToCxf( + userId = userId, + account = account, + ciphers = any(), + ) + } returns throwable.asFailure() + + coEvery { + vaultDiskSource.getSelectedCiphers( + userId = userId, + cipherIds = cipherListViews.mapNotNull { it.id }, + ) + } returns listOf(createMockCipher(number = 1)) + + val result = vaultRepository.exportVaultDataToCxf(ciphers = cipherListViews) + + assertEquals(throwable.asFailure(), result) + } + @Test fun `silentlyDiscoverCredentials should return result`() = runTest { val userId = "userId" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/model/AccountJsonUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/model/AccountJsonUtil.kt new file mode 100644 index 0000000000..3c1dd132d2 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/model/AccountJsonUtil.kt @@ -0,0 +1,68 @@ +package com.x8bit.bitwarden.data.vault.repository.model + +import com.bitwarden.network.model.KdfTypeJson +import com.bitwarden.network.model.UserDecryptionOptionsJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason +import java.time.ZonedDateTime + +/** + * Creates a mock [AccountJson.Profile] for testing purposes. + */ +@Suppress("LongParameterList") +fun createMockAccountJsonProfile( + number: Int, + userId: String = "mockId-$number", + email: String = "mockEmail-$number", + isEmailVerified: Boolean = true, + name: String? = "mockName-$number", + stamp: String = "mockSecurityStamp-$number", + organizationId: String? = "mockOrganizationId-$number", + avatarColorHex: String? = "mockAvatarColorHex-$number", + hasPremium: Boolean = false, + forcePasswordResetReason: ForcePasswordResetReason? = null, + kdfType: KdfTypeJson? = null, + kdfIterations: Int? = null, + kdfMemory: Int? = null, + kdfParallelism: Int? = null, + userDecryptionOptions: UserDecryptionOptionsJson? = null, + isTwoFactorEnabled: Boolean = false, + creationDate: ZonedDateTime = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"), +): AccountJson.Profile = AccountJson.Profile( + userId = userId, + email = email, + isEmailVerified = isEmailVerified, + name = name, + stamp = stamp, + organizationId = organizationId, + avatarColorHex = avatarColorHex, + hasPremium = hasPremium, + forcePasswordResetReason = forcePasswordResetReason, + kdfType = kdfType, + kdfIterations = kdfIterations, + kdfMemory = kdfMemory, + kdfParallelism = kdfParallelism, + userDecryptionOptions = userDecryptionOptions, + isTwoFactorEnabled = isTwoFactorEnabled, + creationDate = creationDate, +) + +/** + * Creates a mock [AccountJson] for testing purposes. + */ +fun createMockAccountJson( + number: Int, + profile: AccountJson.Profile = createMockAccountJsonProfile(number), + tokens: AccountTokensJson = AccountTokensJson( + accessToken = "accessToken-$number", + refreshToken = "refreshToken-$number", + ), + settings: AccountJson.Settings = AccountJson.Settings( + environmentUrlData = null, + ), +): AccountJson = AccountJson( + profile = profile, + tokens = tokens, + settings = settings, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/util/AccountJsonExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/util/AccountJsonExtensionsTest.kt new file mode 100644 index 0000000000..5fbd6e21af --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/util/AccountJsonExtensionsTest.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.data.vault.repository.util + +import com.bitwarden.exporters.Account +import com.x8bit.bitwarden.data.vault.repository.model.createMockAccountJson +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AccountJsonExtensionsTest { + + @Test + fun `toAccountData returns populated AccountData when account is non-null`() { + val account = createMockAccountJson(number = 1) + assertEquals( + Account( + id = "mockId-1", + email = "mockEmail-1", + name = "mockName-1", + ), + account.toSdkAccount(), + ) + } +}