[PM-15056] Add exportVaultDataToCxf function to VaultRepository (#5847)

This commit is contained in:
Patrick Honkonen 2025-09-10 10:40:05 -04:00 committed by GitHub
parent 808d57edc5
commit ba7ee04281
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 264 additions and 0 deletions

View File

@ -24,6 +24,14 @@ interface VaultDiskSource {
*/
suspend fun getCiphers(userId: String): List<SyncResponseJson.Cipher>
/**
* Retrieves all ciphers with the given [cipherIds] from the data source for a given [userId].
*/
suspend fun getSelectedCiphers(
userId: String,
cipherIds: List<String>,
): List<SyncResponseJson.Cipher>
/**
* Retrieves all ciphers from the data source for a given [userId] that contain TOTP codes.
*/

View File

@ -97,6 +97,24 @@ class VaultDiskSourceImpl(
}
}
override suspend fun getSelectedCiphers(
userId: String,
cipherIds: List<String>,
): List<SyncResponseJson.Cipher> {
val entities = ciphersDao.getSelectedCiphers(userId = userId, cipherIds = cipherIds)
return withContext(context = dispatcherManager.default) {
entities
.map { entity ->
async {
json.decodeFromStringWithErrorCallback<SyncResponseJson.Cipher>(
string = entity.cipherJson,
) { Timber.e(it, "Failed to deserialize Cipher in Vault") }
}
}
.awaitAll()
}
}
override suspend fun getTotpCiphers(userId: String): List<SyncResponseJson.Cipher> {
val entities = ciphersDao.getAllTotpCiphers(userId = userId)
return withContext(context = dispatcherManager.default) {

View File

@ -37,6 +37,15 @@ interface CiphersDao {
userId: String,
): List<CipherEntity>
/**
* 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<String>,
): List<CipherEntity>
/**
* Retrieves all ciphers from the database for a given [userId].
*/

View File

@ -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<CipherListView>): Result<String>
/**
* 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.

View File

@ -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<CipherListView>,
): Result<String> {
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

View File

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

View File

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

View File

@ -41,6 +41,12 @@ class FakeCiphersDao : CiphersDao {
override suspend fun getAllCiphers(userId: String): List<CipherEntity> =
storedCiphers.filter { it.userId == userId }
override suspend fun getSelectedCiphers(
userId: String,
cipherIds: List<String>,
): List<CipherEntity> =
storedCiphers.filter { it.userId == userId && it.id in cipherIds }
override suspend fun getAllTotpCiphers(userId: String): List<CipherEntity> =
storedCiphers.filter { it.userId == userId && it.hasTotp }

View File

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

View File

@ -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"

View File

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

View File

@ -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(),
)
}
}