[PM-28471] Migrate individual vault to organization (#6352)

This commit is contained in:
aj-rosado 2026-01-16 19:11:43 +00:00 committed by GitHub
parent 759e0563a9
commit eb18ca04a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1269 additions and 105 deletions

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.collections.Collection
import com.bitwarden.collections.CollectionId
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.EnrollPinResponse
import com.bitwarden.core.InitOrgCryptoRequest
@ -389,6 +390,16 @@ interface VaultSdkSource {
cipherView: CipherView,
): Result<CipherView>
/**
* Re-encrypts the [cipherViews] with the organizations encryption key into the respective [collectionIds]
*/
suspend fun bulkMoveToOrganization(
userId: String,
organizationId: String,
cipherViews: List<CipherView>,
collectionIds: List<CollectionId>,
): Result<List<EncryptionContext>>
/**
* Validates that the given password matches the password hash.
*/

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.collections.Collection
import com.bitwarden.collections.CollectionId
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.DeriveKeyConnectorException
import com.bitwarden.core.DeriveKeyConnectorRequest
@ -451,6 +452,22 @@ class VaultSdkSourceImpl(
.moveToOrganization(cipher = cipherView, organizationId = organizationId)
}
override suspend fun bulkMoveToOrganization(
userId: String,
organizationId: String,
cipherViews: List<CipherView>,
collectionIds: List<CollectionId>,
): Result<List<EncryptionContext>> = runCatchingWithLogs {
getClient(userId = userId)
.vault()
.ciphers()
.prepareCiphersForBulkShare(
organizationId = organizationId,
ciphers = cipherViews,
collectionIds = collectionIds,
)
}
override suspend fun validatePassword(
userId: String,
password: String,

View File

@ -133,4 +133,12 @@ interface CipherManager {
cipherView: CipherView,
collectionIds: List<String>,
): ShareCipherResult
/**
* Migrate the attachments if they don't have their own key
*/
suspend fun migrateAttachments(
userId: String,
cipherView: CipherView,
): Result<CipherView>
}

View File

@ -685,7 +685,7 @@ class CipherManagerImpl(
}
}
private suspend fun migrateAttachments(
override suspend fun migrateAttachments(
userId: String,
cipherView: CipherView,
): Result<CipherView> {

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.manager
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
import kotlinx.coroutines.flow.StateFlow
/**
@ -16,4 +17,16 @@ interface VaultMigrationManager {
* Automatically updated when cipher data, policies, or feature flags change.
*/
val vaultMigrationDataStateFlow: StateFlow<VaultMigrationData>
/**
* Migrates all personal vault items to the specified organization.
*
* @param userId The ID of the user performing the migration.
* @param organizationId The ID of the organization to migrate items to.
* @return Result indicating success or failure of the migration operation.
*/
suspend fun migratePersonalVault(
userId: String,
organizationId: String,
): MigratePersonalVaultResult
}

View File

@ -1,8 +1,18 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.collections.CollectionType
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.network.model.BulkShareCiphersJsonRequest
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.toCipherWithIdJsonRequest
import com.bitwarden.network.service.CiphersService
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
@ -10,7 +20,14 @@ import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndUnlocked
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
import com.x8bit.bitwarden.data.vault.repository.util.updateFromMiniResponse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -20,6 +37,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import timber.log.Timber
/**
* Default implementation of [VaultMigrationManager].
@ -34,6 +52,9 @@ import kotlinx.coroutines.flow.update
class VaultMigrationManagerImpl(
private val authDiskSource: AuthDiskSource,
private val vaultDiskSource: VaultDiskSource,
private val vaultRepository: VaultRepository,
private val vaultSdkSource: VaultSdkSource,
private val ciphersService: CiphersService,
private val settingsDiskSource: SettingsDiskSource,
private val policyManager: PolicyManager,
private val featureFlagManager: FeatureFlagManager,
@ -152,4 +173,149 @@ class VaultMigrationManagerImpl(
featureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) &&
isNetworkConnected &&
hasPersonalCiphers
override suspend fun migratePersonalVault(
userId: String,
organizationId: String,
): MigratePersonalVaultResult {
val vaultData = vaultRepository.vaultDataStateFlow.value.data
?: return MigratePersonalVaultResult.Failure(
IllegalStateException("Vault data not available"),
)
val defaultUserCollection = getDefaultUserCollection(vaultData, organizationId)
.getOrElse { return MigratePersonalVaultResult.Failure(it) }
val personalCiphers = getPersonalCipherViews(vaultData)
.getOrElse { return MigratePersonalVaultResult.Failure(it) }
if (personalCiphers.isEmpty()) {
mutableVaultMigrationDataStateFlow.update { VaultMigrationData.NoMigrationRequired }
return MigratePersonalVaultResult.Success
}
val cipherIds = personalCiphers.mapNotNull { it.id }
val encryptedCiphers = vaultDiskSource.getSelectedCiphers(
userId = userId,
cipherIds = cipherIds,
)
val encryptedCiphersMap = encryptedCiphers.associateBy { it.id }
val processedCipherViews = migrateAttachments(userId, personalCiphers)
.getOrElse { return MigratePersonalVaultResult.Failure(it) }
encryptAndShareCiphers(
userId = userId,
organizationId = organizationId,
processedCipherViews = processedCipherViews,
encryptedCiphersMap = encryptedCiphersMap,
collectionIds = listOfNotNull(defaultUserCollection.id),
).getOrElse { return MigratePersonalVaultResult.Failure(it) }
mutableVaultMigrationDataStateFlow.update { VaultMigrationData.NoMigrationRequired }
return MigratePersonalVaultResult.Success
}
private fun getDefaultUserCollection(
vaultData: VaultData,
organizationId: String,
): Result<CollectionView> {
val collection = vaultData.collectionViewList.find {
it.type == CollectionType.DEFAULT_USER_COLLECTION && it.organizationId == organizationId
}
return collection?.asSuccess()
?: IllegalStateException("Default user collection not found for organization")
.asFailure()
}
private suspend fun getPersonalCipherViews(
vaultData: VaultData,
): Result<List<CipherView>> = runCatching {
vaultData.decryptCipherListResult.successes
.filter { it.organizationId == null }
.mapNotNull { cipherListView ->
cipherListView.id?.let { cipherId ->
vaultRepository
.getCipher(cipherId = cipherId)
.toCipherViewOrFailure()
?.getOrElse { error ->
return error.asFailure()
}
}
}
}
private suspend fun migrateAttachments(
userId: String,
personalCiphers: List<CipherView>,
): Result<List<CipherView>> = runCatching {
personalCiphers.map { cipherView ->
vaultRepository
.migrateAttachments(userId = userId, cipherView = cipherView)
.getOrElse { error ->
return error.asFailure()
}
}
}
private suspend fun encryptAndShareCiphers(
userId: String,
organizationId: String,
processedCipherViews: List<CipherView>,
encryptedCiphersMap: Map<String, SyncResponseJson.Cipher>,
collectionIds: List<String>,
): Result<Unit> {
return vaultSdkSource
.bulkMoveToOrganization(
userId = userId,
organizationId = organizationId,
cipherViews = processedCipherViews,
collectionIds = collectionIds,
)
.map { encryptionContexts ->
encryptionContexts.mapNotNull { context ->
context.cipher.id?.let { cipherId ->
context
.toEncryptedNetworkCipher()
.toCipherWithIdJsonRequest(id = cipherId)
}
}
}
.flatMap { cipherRequests ->
ciphersService.bulkShareCiphers(
body = BulkShareCiphersJsonRequest(
ciphers = cipherRequests,
collectionIds = collectionIds,
),
)
}
.map { bulkShareResponse ->
bulkShareResponse.cipherMiniResponse.forEach { miniResponse ->
encryptedCiphersMap[miniResponse.id]?.let {
vaultDiskSource.saveCipher(
userId = userId,
cipher = it.updateFromMiniResponse(
miniResponse = miniResponse,
collectionIds = collectionIds,
),
)
}
}
}
}
private fun GetCipherResult.toCipherViewOrFailure(): Result<CipherView>? =
when (this) {
GetCipherResult.CipherNotFound -> {
Timber.e("Cipher not found for vault migration.")
null
}
is GetCipherResult.Failure -> {
Timber.e(this.error, "Failed to decrypt cipher for vault migration.")
this.error.asFailure()
}
is GetCipherResult.Success -> this.cipherView.asSuccess()
}
}

View File

@ -43,6 +43,7 @@ import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManagerImpl
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -64,6 +65,9 @@ object VaultManagerModule {
fun provideVaultMigrationManager(
authDiskSource: AuthDiskSource,
vaultDiskSource: VaultDiskSource,
vaultRepository: VaultRepository,
vaultSdkSource: VaultSdkSource,
ciphersService: CiphersService,
settingsDiskSource: SettingsDiskSource,
vaultLockManager: VaultLockManager,
policyManager: PolicyManager,
@ -73,6 +77,9 @@ object VaultManagerModule {
): VaultMigrationManager = VaultMigrationManagerImpl(
authDiskSource = authDiskSource,
vaultDiskSource = vaultDiskSource,
vaultRepository = vaultRepository,
vaultSdkSource = vaultSdkSource,
ciphersService = ciphersService,
settingsDiskSource = settingsDiskSource,
vaultLockManager = vaultLockManager,
policyManager = policyManager,

View File

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.vault.repository.model
/**
* Models result of migrating the personal vault.
*/
sealed class MigratePersonalVaultResult {
/**
* Personal vault migrated successfully.
*/
data object Success : MigratePersonalVaultResult()
/**
* Generic error while migrating personal vault
*/
data class Failure(val error: Throwable?) : MigratePersonalVaultResult()
}

View File

@ -5,6 +5,7 @@ package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.core.data.repository.util.SpecialCharWithPrecedenceComparator
import com.bitwarden.network.model.AttachmentJsonRequest
import com.bitwarden.network.model.CipherJsonRequest
import com.bitwarden.network.model.CipherMiniResponseJson
import com.bitwarden.network.model.CipherRepromptTypeJson
import com.bitwarden.network.model.CipherTypeJson
import com.bitwarden.network.model.FieldTypeJson
@ -109,6 +110,32 @@ fun Cipher.toEncryptedNetworkCipherResponse(
archivedDate = archivedDate?.let { ZonedDateTime.ofInstant(it, ZoneOffset.UTC) },
)
/**
* Updates a [SyncResponseJson.Cipher] with metadata from a
* [CipherMiniResponseJson.CipherMiniResponse].
* This is useful for updating local cipher data after bulk operations that return mini responses.
*
* @param miniResponse The mini response containing updated cipher metadata.
* @param collectionIds Optional list of collection IDs to update.
* If null, keeps existing collection IDs.
* @return A new [SyncResponseJson.Cipher] with updated fields from the mini response.
*/
fun SyncResponseJson.Cipher.updateFromMiniResponse(
miniResponse: CipherMiniResponseJson.CipherMiniResponse,
collectionIds: List<String>? = null,
): SyncResponseJson.Cipher = copy(
organizationId = miniResponse.organizationId,
collectionIds = collectionIds ?: this.collectionIds,
revisionDate = miniResponse.revisionDate,
key = miniResponse.key,
attachments = miniResponse.attachments,
archivedDate = miniResponse.archivedDate,
deletedDate = miniResponse.deletedDate,
reprompt = miniResponse.reprompt,
shouldOrganizationUseTotp = miniResponse.shouldOrganizationUseTotp,
type = miniResponse.type,
)
/**
* Converts a Bitwarden SDK [Card] object to a corresponding
* [SyncResponseJson.Cipher.Card] object.

View File

@ -88,6 +88,7 @@ private fun MigrateToMyItemsDialogs(
BitwardenBasicDialog(
title = dialog.title(),
message = dialog.message(),
throwable = dialog.throwable,
onDismissRequest = onDismissRequest,
)
}

View File

@ -3,20 +3,24 @@ package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.repository.error.MissingPropertyException
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import javax.inject.Inject
private const val KEY_STATE = "state"
@ -27,8 +31,10 @@ private const val KEY_STATE = "state"
@HiltViewModel
class MigrateToMyItemsViewModel @Inject constructor(
private val organizationEventManager: OrganizationEventManager,
private val vaultMigrationManager: VaultMigrationManager,
vaultSyncManager: VaultSyncManager,
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
) : BaseViewModel<MigrateToMyItemsState, MigrateToMyItemsEvent, MigrateToMyItemsAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val args = savedStateHandle.toMigrateToMyItemsArgs()
@ -71,11 +77,28 @@ class MigrateToMyItemsViewModel @Inject constructor(
}
viewModelScope.launch {
// TODO: Replace `delay` with actual migration using `state.organizationId` (PM-28444).
delay(timeMillis = 100L)
val userId = authRepository.userStateFlow.value?.activeUserId
if (userId == null) {
trySendAction(
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
result = MigratePersonalVaultResult.Failure(
error = MissingPropertyException(
propertyName = "UserId",
),
),
),
)
return@launch
}
val result = vaultMigrationManager.migratePersonalVault(
userId = userId,
organizationId = state.organizationId,
)
trySendAction(
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
success = true,
result = result,
),
)
}
@ -108,22 +131,28 @@ class MigrateToMyItemsViewModel @Inject constructor(
private fun handleMigrateToMyItemsResultReceived(
action: MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived,
) {
if (action.success) {
organizationEventManager.trackEvent(
event = OrganizationEvent.ItemOrganizationAccepted,
)
clearDialog()
sendEvent(MigrateToMyItemsEvent.NavigateToVault)
} else {
mutableStateFlow.update {
it.copy(
dialog = MigrateToMyItemsState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.failed_to_migrate_items_to_x.asText(
it.organizationName,
),
),
when (val result = action.result) {
is MigratePersonalVaultResult.Success -> {
organizationEventManager.trackEvent(
event = OrganizationEvent.ItemOrganizationAccepted,
)
clearDialog()
sendEvent(MigrateToMyItemsEvent.NavigateToVault)
}
is MigratePersonalVaultResult.Failure -> {
Timber.e(result.error, "Failed to migrate personal vault")
mutableStateFlow.update {
it.copy(
dialog = MigrateToMyItemsState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.failed_to_migrate_items_to_x.asText(
it.organizationName,
),
throwable = result.error,
),
)
}
}
}
}
@ -161,6 +190,7 @@ data class MigrateToMyItemsState(
data class Error(
val title: Text,
val message: Text,
val throwable: Throwable?,
) : DialogState()
}
}
@ -218,8 +248,7 @@ sealed class MigrateToMyItemsAction {
* The result of the migration has been received.
*/
data class MigrateToMyItemsResultReceived(
// TODO: Replace `success` with actual migration result (PM-28444).
val success: Boolean,
val result: MigratePersonalVaultResult,
) : Internal()
}
}

View File

@ -1088,6 +1088,66 @@ class VaultSdkSourceTest {
assertEquals(expectedResult.asSuccess(), result)
}
@Test
fun `bulkMoveToOrganization should call SDK and return Result with correct data`() = runTest {
val userId = "userId"
val organizationId = "organizationId"
val cipherViews = listOf(mockk<CipherView>(), mockk<CipherView>())
val collectionIds = listOf("collectionId-1", "collectionId-2")
val expectedResult = listOf(mockk<EncryptionContext>(), mockk<EncryptionContext>())
coEvery {
ciphersClient.prepareCiphersForBulkShare(
organizationId = organizationId,
ciphers = cipherViews,
collectionIds = collectionIds,
)
} returns expectedResult
val result = vaultSdkSource.bulkMoveToOrganization(
userId = userId,
organizationId = organizationId,
cipherViews = cipherViews,
collectionIds = collectionIds,
)
assertEquals(expectedResult.asSuccess(), result)
coVerify(exactly = 1) {
ciphersClient.prepareCiphersForBulkShare(
organizationId = organizationId,
ciphers = cipherViews,
collectionIds = collectionIds,
)
}
}
@Test
fun `bulkMoveToOrganization should return Failure when BitwardenException is thrown`() =
runTest {
val userId = "userId"
val organizationId = "organizationId"
val cipherViews = listOf(mockk<CipherView>())
val collectionIds = listOf("collectionId-1")
val error = BitwardenException.Decrypt(mockk<DecryptException>("mockException"))
coEvery {
ciphersClient.prepareCiphersForBulkShare(
organizationId = organizationId,
ciphers = cipherViews,
collectionIds = collectionIds,
)
} throws error
val result = vaultSdkSource.bulkMoveToOrganization(
userId = userId,
organizationId = organizationId,
cipherViews = cipherViews,
collectionIds = collectionIds,
)
assertEquals(error.asFailure(), result)
}
@Test
fun `validatePassword should call SDK and a Result with correct data`() = runTest {
val userId = "userId"

View File

@ -1,11 +1,18 @@
package com.x8bit.bitwarden.data.vault.manager
import app.cash.turbine.test
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.createMockCipher
import com.bitwarden.network.model.createMockCipherMiniResponseJson
import com.bitwarden.network.model.createMockPolicy
import com.bitwarden.network.model.createMockSyncResponse
import com.bitwarden.send.SendView
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.FolderView
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.UserStateJson
@ -17,24 +24,39 @@ import com.x8bit.bitwarden.data.platform.manager.model.NetworkConnection
import com.x8bit.bitwarden.data.platform.manager.model.NetworkSignalStrength
import com.x8bit.bitwarden.data.platform.manager.util.FakeNetworkConnectionManager
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.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
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockEncryptionContext
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkCipher
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Instant
import java.time.ZonedDateTime
@Suppress("LargeClass")
class VaultMigrationManagerTest {
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val fakeDispatcherManager = FakeDispatcherManager()
private val mutableHasPersonalCiphersFlow = MutableStateFlow(false)
private val vaultDiskSource: VaultDiskSource = mockk {
private val vaultDiskSource: VaultDiskSource = mockk(relaxed = true) {
every { hasPersonalCiphersFlow(any()) } returns mutableHasPersonalCiphersFlow
}
@ -64,10 +86,32 @@ class VaultMigrationManagerTest {
networkConnection = NetworkConnection.Wifi(strength = NetworkSignalStrength.GOOD),
)
private val mutableVaultDataFlow = MutableStateFlow<DataState<VaultData>>(
value = DataState.Loading,
)
private val vaultRepository: VaultRepository = mockk {
every { vaultDataStateFlow } returns mutableVaultDataFlow
coEvery {
getCipher(any())
} returns GetCipherResult.Success(
cipherView = createMockCipherView(number = 1),
)
}
private val vaultSdkSource: VaultSdkSource =
mockk(relaxed = true)
private val ciphersService: com.bitwarden.network.service.CiphersService =
mockk(relaxed = true)
private fun createVaultMigrationManager(): VaultMigrationManager =
VaultMigrationManagerImpl(
authDiskSource = fakeAuthDiskSource,
vaultDiskSource = vaultDiskSource,
vaultRepository = vaultRepository,
vaultSdkSource = vaultSdkSource,
ciphersService = ciphersService,
settingsDiskSource = settingsDiskSource,
vaultLockManager = vaultLockManager,
policyManager = policyManager,
@ -621,8 +665,523 @@ class VaultMigrationManagerTest {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
}
}
@Test
fun `migratePersonalVault should succeed and call migrateAttachments for each cipher`() =
runTest {
val userId = "mockId-1"
val organizationId = "mockOrganizationId-1"
mutableVaultDataFlow.value = DataState.Loaded(
data = createVaultData(
cipherListView = createMockCipherListView(number = 1, organizationId = null),
collectionViewList = listOf(
createMockCollectionView(
number = 1,
type = com.bitwarden.collections.CollectionType.DEFAULT_USER_COLLECTION,
),
),
),
)
coEvery {
vaultRepository.getCipher(any())
} returns GetCipherResult.Success(createMockCipherView(number = 1))
coEvery {
vaultDiskSource.getSelectedCiphers(userId, any())
} returns listOf(
createMockCipher(number = 1, organizationId = null),
)
coEvery {
vaultRepository.migrateAttachments(any(), any())
} returns Result.success(
createMockCipherView(number = 1),
)
coEvery {
vaultSdkSource.bulkMoveToOrganization(
userId = any(),
organizationId = any(),
cipherViews = any(),
collectionIds = any(),
)
} returns Result.success(
listOf(
createMockEncryptionContext(
number = 1,
cipher = createMockSdkCipher(number = 1),
),
),
)
coEvery {
ciphersService.bulkShareCiphers(any())
} returns Result.success(createMockCipherMiniResponseJson(1))
val vaultMigrationManager = createVaultMigrationManager()
val result = vaultMigrationManager.migratePersonalVault(userId, organizationId)
assertTrue(result is MigratePersonalVaultResult.Success)
coVerify(exactly = 1) { vaultRepository.migrateAttachments(userId, any()) }
coVerify(exactly = 1) { vaultDiskSource.saveCipher(userId, any()) }
}
@Test
fun `migratePersonalVault should fail when attachment migration fails`() = runTest {
val userId = "mockId-1"
val organizationId = "mockOrganizationId-1"
mutableVaultDataFlow.value = DataState.Loaded(
data = createVaultData(
cipherListView = createMockCipherListView(number = 1, organizationId = null),
collectionViewList = listOf(
createMockCollectionView(
number = 1,
type = com.bitwarden.collections.CollectionType.DEFAULT_USER_COLLECTION,
),
),
),
)
coEvery {
vaultRepository.getCipher(any())
} returns GetCipherResult.Success(createMockCipherView(number = 1))
coEvery {
vaultDiskSource.getSelectedCiphers(userId, any())
} returns listOf(
createMockCipher(number = 1, organizationId = null),
)
val attachmentError = IllegalStateException("Attachment migration failed")
coEvery {
vaultRepository.migrateAttachments(any(), any())
} returns Result.failure(attachmentError)
val vaultMigrationManager = createVaultMigrationManager()
val result = vaultMigrationManager.migratePersonalVault(userId, organizationId)
assertTrue(result is MigratePersonalVaultResult.Failure)
assertEquals(
attachmentError,
(result as MigratePersonalVaultResult.Failure).error,
)
coVerify { vaultRepository.migrateAttachments(userId, any()) }
coVerify(exactly = 0) {
vaultSdkSource.bulkMoveToOrganization(
userId = any(),
organizationId = any(),
cipherViews = any(),
collectionIds = any(),
)
}
}
@Test
fun `migratePersonalVault should fail when vault data is not available`() = runTest {
val userId = "mockId-1"
val organizationId = "mockOrganizationId-1"
// Setup mocks - vault data is null
val mockDataState = mockk<DataState<VaultData>> {
every { data } returns null
}
every { vaultRepository.vaultDataStateFlow } returns MutableStateFlow(mockDataState)
val vaultMigrationManager = createVaultMigrationManager()
val result = vaultMigrationManager.migratePersonalVault(userId, organizationId)
assertTrue(result is MigratePersonalVaultResult.Failure)
assertTrue((result as MigratePersonalVaultResult.Failure).error is IllegalStateException)
assertEquals(
"Vault data not available",
(result as MigratePersonalVaultResult.Failure).error?.message,
)
}
@Test
fun `migratePersonalVault should fail when default collection not found`() = runTest {
val userId = "mockId-1"
val organizationId = "mockOrganizationId-1"
val cipherListView =
createMockCipherListView(number = 2, organizationId = "mockOrganizationId-fail")
mutableVaultDataFlow.value = DataState.Loaded(
data = createVaultData(cipherListView = cipherListView),
)
val vaultMigrationManager = createVaultMigrationManager()
val result = vaultMigrationManager.migratePersonalVault(userId, organizationId)
assertTrue(result is MigratePersonalVaultResult.Failure)
assertTrue((result as MigratePersonalVaultResult.Failure).error is IllegalStateException)
assertEquals(
"Default user collection not found for organization",
(result as MigratePersonalVaultResult.Failure).error?.message,
)
}
@Test
fun `migratePersonalVault should succeed immediately when no personal ciphers exist`() =
runTest {
val userId = "mockId-1"
val organizationId = "mockOrganizationId-1"
mutableVaultDataFlow.value = DataState.Loaded(
data = createVaultData(
collectionViewList = listOf(
createMockCollectionView(
number = 1,
type = com.bitwarden.collections.CollectionType.DEFAULT_USER_COLLECTION,
),
),
),
)
val vaultMigrationManager = createVaultMigrationManager()
val result = vaultMigrationManager.migratePersonalVault(userId, organizationId)
assertTrue(result is MigratePersonalVaultResult.Success)
coVerify(exactly = 0) {
vaultRepository.migrateAttachments(
userId = any(),
cipherView = any(),
)
}
coVerify(exactly = 0) {
vaultSdkSource.bulkMoveToOrganization(
userId = any(),
organizationId = any(),
cipherViews = any(),
collectionIds = any(),
)
}
}
@Test
fun `migratePersonalVault should fail when cipher decryption fails`() = runTest {
val userId = "mockId-1"
val organizationId = "mockOrganizationId-1"
mutableVaultDataFlow.value = DataState.Loaded(
data = createVaultData(
cipherListView = createMockCipherListView(number = 1, organizationId = null),
collectionViewList = listOf(
createMockCollectionView(
number = 1,
type = com.bitwarden.collections.CollectionType.DEFAULT_USER_COLLECTION,
),
),
),
)
coEvery {
vaultRepository.getCipher(any())
} returns GetCipherResult.Failure(IllegalStateException("Decryption failed"))
val vaultMigrationManager = createVaultMigrationManager()
val result = vaultMigrationManager.migratePersonalVault(userId, organizationId)
// Should fail when decryption fails (fail-fast behavior)
assertTrue(result is MigratePersonalVaultResult.Failure)
assertEquals(
"Decryption failed",
(result as MigratePersonalVaultResult.Failure).error?.message,
)
coVerify(exactly = 0) { vaultRepository.migrateAttachments(any(), any()) }
coVerify(exactly = 0) { vaultDiskSource.getSelectedCiphers(any(), any()) }
}
@Test
fun `migratePersonalVault should fail when bulkMoveToOrganization fails`() = runTest {
val userId = "mockId-1"
val organizationId = "mockOrganizationId-1"
mutableVaultDataFlow.value = DataState.Loaded(
data = createVaultData(
cipherListView = createMockCipherListView(number = 1, organizationId = null),
collectionViewList = listOf(
createMockCollectionView(
number = 1,
type = com.bitwarden.collections.CollectionType.DEFAULT_USER_COLLECTION,
),
),
),
)
coEvery {
vaultRepository.getCipher(any())
} returns GetCipherResult.Success(createMockCipherView(number = 1))
coEvery {
vaultDiskSource.getSelectedCiphers(userId, any())
} returns listOf(
createMockCipher(number = 1, organizationId = null),
)
coEvery {
vaultRepository.migrateAttachments(any(), any())
} returns Result.success(
value = createMockCipherView(number = 1),
)
val encryptionError = IllegalStateException("Encryption failed")
coEvery {
vaultSdkSource.bulkMoveToOrganization(
userId = any(),
organizationId = any(),
cipherViews = any(),
collectionIds = any(),
)
} returns Result.failure(encryptionError)
val vaultMigrationManager = createVaultMigrationManager()
val result = vaultMigrationManager.migratePersonalVault(userId, organizationId)
assertTrue(result is MigratePersonalVaultResult.Failure)
assertEquals(encryptionError, (result as MigratePersonalVaultResult.Failure).error)
}
@Test
fun `migratePersonalVault should fail when bulkShareCiphers fails`() = runTest {
val userId = "mockId-1"
val organizationId = "mockOrganizationId-1"
mutableVaultDataFlow.value = DataState.Loaded(
data = createVaultData(
cipherListView = createMockCipherListView(number = 1, organizationId = null),
collectionViewList = listOf(
createMockCollectionView(
number = 1,
type = com.bitwarden.collections.CollectionType.DEFAULT_USER_COLLECTION,
),
),
),
)
coEvery {
vaultRepository.getCipher(any())
} returns GetCipherResult.Success(createMockCipherView(number = 1))
coEvery {
vaultDiskSource.getSelectedCiphers(userId, any())
} returns listOf(
createMockCipher(number = 1, organizationId = null),
)
coEvery {
vaultRepository.migrateAttachments(
userId = any(),
cipherView = any(),
)
} returns Result.success(
createMockCipherView(number = 1),
)
coEvery {
vaultSdkSource.bulkMoveToOrganization(
userId = any(),
organizationId = any(),
cipherViews = any(),
collectionIds = any(),
)
} returns Result.success(
listOf(
createMockEncryptionContext(
number = 1,
cipher = createMockSdkCipher(number = 1),
),
),
)
val shareError = IllegalStateException("Share failed")
coEvery {
ciphersService.bulkShareCiphers(any())
} returns Result.failure(shareError)
val vaultMigrationManager = createVaultMigrationManager()
val result = vaultMigrationManager.migratePersonalVault(userId, organizationId)
assertTrue(result is MigratePersonalVaultResult.Failure)
assertEquals(shareError, (result as MigratePersonalVaultResult.Failure).error)
}
@Test
fun `migratePersonalVault should skip cipher when not found in encrypted ciphers map`() =
runTest {
val userId = "mockId-1"
val organizationId = "mockOrganizationId-1"
mutableVaultDataFlow.value = DataState.Loaded(
data = createVaultData(
cipherListView = createMockCipherListView(number = 1, organizationId = null),
collectionViewList = listOf(
createMockCollectionView(
number = 1,
type = com.bitwarden.collections.CollectionType.DEFAULT_USER_COLLECTION,
),
),
),
)
coEvery {
vaultRepository.getCipher(any())
} returns GetCipherResult.Success(createMockCipherView(number = 1))
// Return encrypted cipher with different ID than what's in mini response
coEvery {
vaultDiskSource.getSelectedCiphers(userId, any())
} returns listOf(
createMockCipher(number = 1, id = "different-id", organizationId = null),
)
coEvery {
vaultRepository.migrateAttachments(any(), any())
} returns Result.success(
createMockCipherView(number = 1),
)
coEvery {
vaultSdkSource.bulkMoveToOrganization(
userId = any(),
organizationId = any(),
cipherViews = any(),
collectionIds = any(),
)
} returns Result.success(
listOf(
createMockEncryptionContext(
number = 1,
cipher = createMockSdkCipher(number = 1),
),
),
)
// Return mini response with ID that doesn't match encrypted cipher
coEvery {
ciphersService.bulkShareCiphers(any())
} returns Result.success(
createMockCipherMiniResponseJson(1),
)
val vaultMigrationManager = createVaultMigrationManager()
val result = vaultMigrationManager.migratePersonalVault(userId, organizationId)
assertTrue(result is MigratePersonalVaultResult.Success)
// Verify saveCipher was not called since cipher wasn't found in map
coVerify(exactly = 0) {
vaultDiskSource.saveCipher(any(), any())
}
}
@Test
fun `migratePersonalVault should skip ciphers with null IDs in cipher list view`() = runTest {
val userId = "mockId-1"
val organizationId = "mockOrganizationId-1"
val mockCipherListViewWithNullId = createMockCipherListView(
number = 1,
organizationId = null,
).copy(id = null)
mutableVaultDataFlow.value = DataState.Loaded(
data = createVaultData(
cipherListView = mockCipherListViewWithNullId,
collectionViewList = listOf(
createMockCollectionView(
number = 1,
type = com.bitwarden.collections.CollectionType.DEFAULT_USER_COLLECTION,
),
),
),
)
val vaultMigrationManager = createVaultMigrationManager()
val result = vaultMigrationManager.migratePersonalVault(userId, organizationId)
// Should succeed with empty list (no ciphers to migrate)
assertTrue(result is MigratePersonalVaultResult.Success)
// Verify getCipher was never called since cipher had null ID
coVerify(exactly = 0) {
vaultRepository.getCipher(any())
}
}
@Test
fun `migratePersonalVault should skip cipher not found but continue with others`() = runTest {
val userId = "mockId-1"
val organizationId = "mockOrganizationId-1"
// Create vault data with 2 ciphers
val mockDecryptResult = createMockDecryptCipherListResult(
number = 1,
successes = listOf(
createMockCipherListView(number = 1, organizationId = null),
createMockCipherListView(number = 2, organizationId = null),
),
)
mutableVaultDataFlow.value = DataState.Loaded(
data = VaultData(
decryptCipherListResult = mockDecryptResult,
collectionViewList = listOf(
createMockCollectionView(
number = 1,
type = com.bitwarden.collections.CollectionType.DEFAULT_USER_COLLECTION,
),
),
folderViewList = emptyList(),
sendViewList = emptyList(),
),
)
// First cipher not found, second succeeds
coEvery {
vaultRepository.getCipher(cipherId = "mockId-1")
} returns GetCipherResult.CipherNotFound
coEvery {
vaultRepository.getCipher(cipherId = "mockId-2")
} returns GetCipherResult.Success(createMockCipherView(number = 2))
coEvery {
vaultDiskSource.getSelectedCiphers(userId, any())
} returns listOf(
createMockCipher(number = 2, organizationId = null),
)
coEvery {
vaultRepository.migrateAttachments(any(), any())
} returns Result.success(
createMockCipherView(number = 2),
)
coEvery {
vaultSdkSource.bulkMoveToOrganization(
userId = any(),
organizationId = any(),
cipherViews = any(),
collectionIds = any(),
)
} returns Result.success(
listOf(
createMockEncryptionContext(
number = 2,
cipher = createMockSdkCipher(number = 2),
),
),
)
coEvery {
ciphersService.bulkShareCiphers(any())
} returns Result.success(createMockCipherMiniResponseJson(2))
val vaultMigrationManager = createVaultMigrationManager()
val result = vaultMigrationManager.migratePersonalVault(userId, organizationId)
// Should succeed, only migrating the second cipher
assertTrue(result is MigratePersonalVaultResult.Success)
coVerify(exactly = 1) {
vaultRepository.migrateAttachments(userId, match { it.id == "mockId-2" })
}
}
}
private fun createVaultData(
cipherListView: CipherListView? = null,
collectionViewList: List<CollectionView> = emptyList(),
folderViewList: List<FolderView> = emptyList(),
sendViewList: List<SendView> = emptyList(),
): VaultData =
VaultData(
decryptCipherListResult = createMockDecryptCipherListResult(
number = 1,
successes = cipherListView?.let { listOf(it) } ?: emptyList(),
),
collectionViewList = collectionViewList,
folderViewList = folderViewList,
sendViewList = sendViewList,
)
private val MOCK_USER_STATE = UserStateJson(
activeUserId = "mockId-1",
accounts = mapOf(

View File

@ -9,6 +9,7 @@ import com.bitwarden.network.model.createMockAttachmentJsonRequest
import com.bitwarden.network.model.createMockCard
import com.bitwarden.network.model.createMockCipher
import com.bitwarden.network.model.createMockCipherJsonRequest
import com.bitwarden.network.model.createMockCipherMiniResponse
import com.bitwarden.network.model.createMockField
import com.bitwarden.network.model.createMockIdentity
import com.bitwarden.network.model.createMockLogin
@ -437,4 +438,54 @@ class VaultSdkCipherExtensionsTest {
val loginType = result.type as CipherListViewType.Card
assertNull(loginType.v1.brand)
}
@Test
fun `updateFromMiniResponse should update cipher with mini response data`() {
val originalCipher = createMockCipher(
number = 1,
organizationId = null,
collectionIds = emptyList(),
)
val miniResponse = createMockCipherMiniResponse(number = 2)
val result = originalCipher.updateFromMiniResponse(
miniResponse = miniResponse,
collectionIds = listOf("collection-1"),
)
assertEquals(miniResponse.organizationId, result.organizationId)
assertEquals(listOf("collection-1"), result.collectionIds)
assertEquals(miniResponse.revisionDate, result.revisionDate)
assertEquals(miniResponse.key, result.key)
assertEquals(miniResponse.attachments, result.attachments)
assertEquals(miniResponse.archivedDate, result.archivedDate)
assertEquals(miniResponse.deletedDate, result.deletedDate)
assertEquals(miniResponse.reprompt, result.reprompt)
assertEquals(miniResponse.shouldOrganizationUseTotp, result.shouldOrganizationUseTotp)
// Verify unchanged fields remain the same
assertEquals(originalCipher.name, result.name)
assertEquals(originalCipher.notes, result.notes)
assertEquals(originalCipher.id, result.id)
}
@Test
fun `updateFromMiniResponse should preserve existing collectionIds when not provided`() {
val originalCipher = createMockCipher(
number = 1,
collectionIds = listOf("original-collection-1", "original-collection-2"),
)
val miniResponse = createMockCipherMiniResponse(number = 2)
val result = originalCipher.updateFromMiniResponse(
miniResponse = miniResponse,
collectionIds = null,
)
assertEquals(
listOf("original-collection-1", "original-collection-2"),
result.collectionIds,
)
}
}

View File

@ -143,6 +143,7 @@ class MigrateToMyItemsScreenTest : BitwardenComposeTest() {
dialog = MigrateToMyItemsState.DialogState.Error(
title = "An error has occurred".asText(),
message = "Failed to migrate items".asText(),
throwable = null,
),
)
@ -162,6 +163,7 @@ class MigrateToMyItemsScreenTest : BitwardenComposeTest() {
dialog = MigrateToMyItemsState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = "Failed to migrate items".asText(),
throwable = IllegalStateException("Missing property"),
),
)

View File

@ -2,12 +2,19 @@ package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.data.repository.error.MissingPropertyException
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -15,6 +22,7 @@ import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
@ -27,7 +35,20 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
private val mockOrganizationEventManager: OrganizationEventManager = mockk {
every { trackEvent(any()) } just runs
}
private val mutableUserStateFlow = MutableStateFlow<UserState?>(
mockk {
every { activeUserId } returns "test-user-id"
},
)
private val mockVaultMigrationManager: VaultMigrationManager = mockk {
coEvery {
migratePersonalVault(any(), any())
} returns MigratePersonalVaultResult.Success
}
private val mockVaultSyncManager: VaultSyncManager = mockk(relaxed = true)
private val mockAuthRepository: AuthRepository = mockk {
every { userStateFlow } returns mutableUserStateFlow
}
@BeforeEach
fun setup() {
@ -43,6 +64,7 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
fun `initial state should be set from organization data`() {
val viewModel = createViewModel()
assertEquals(ORGANIZATION_NAME, viewModel.stateFlow.value.organizationName)
assertEquals(ORGANIZATION_ID, viewModel.stateFlow.value.organizationId)
assertNull(viewModel.stateFlow.value.dialog)
}
@ -50,15 +72,23 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
fun `AcceptClicked should show loading dialog and trigger migration`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(null, awaitItem().dialog)
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
val loadingState = awaitItem()
assert(loadingState.dialog is MigrateToMyItemsState.DialogState.Loading)
assertEquals(
BitwardenString.migrating_items_to_x.asText(ORGANIZATION_NAME),
(loadingState.dialog as MigrateToMyItemsState.DialogState.Loading).message,
DEFAULT_STATE.copy(
dialog = MigrateToMyItemsState.DialogState.Loading(
message = BitwardenString.migrating_items_to_x.asText(ORGANIZATION_NAME),
),
),
awaitItem(),
)
// Migration completes successfully and clears the dialog
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
}
}
@ -72,19 +102,53 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `AcceptClicked should show error dialog when userId is null`() = runTest {
mutableUserStateFlow.value = null
val viewModel = createViewModel()
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
val dialog = viewModel.stateFlow.value.dialog as MigrateToMyItemsState.DialogState.Error
val throwableReference = dialog.throwable
assert(throwableReference is MissingPropertyException)
assertEquals(
"Missing the required UserId property",
throwableReference?.message,
)
assertEquals(
MigrateToMyItemsState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.failed_to_migrate_items_to_x.asText(
ORGANIZATION_NAME,
),
throwable = throwableReference,
),
viewModel.stateFlow.value.dialog,
)
coVerify(exactly = 0) {
mockVaultMigrationManager.migratePersonalVault(any(), any())
}
}
@Suppress("MaxLineLength")
@Test
fun `MigrateToMyItemsResultReceived with success should track ItemOrganizationAccepted event, clear dialog, and navigate to vault`() =
runTest {
val viewModel = createViewModel()
// First show the loading dialog
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
viewModel.eventFlow.test {
// First show the loading dialog
viewModel.trySendAction(MigrateToMyItemsAction.AcceptClicked)
// Migration completes and sends NavigateToVault event
assertEquals(MigrateToMyItemsEvent.NavigateToVault, awaitItem())
viewModel.trySendAction(
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
success = true,
result = MigratePersonalVaultResult.Success,
),
)
assertEquals(MigrateToMyItemsEvent.NavigateToVault, awaitItem())
@ -108,7 +172,7 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
success = false,
result = MigratePersonalVaultResult.Failure(null),
),
)
@ -156,7 +220,7 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
// First show an error dialog
viewModel.trySendAction(
MigrateToMyItemsAction.Internal.MigrateToMyItemsResultReceived(
success = false,
result = MigratePersonalVaultResult.Failure(null),
),
)
val errorState = awaitItem()
@ -170,19 +234,23 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
}
private fun createViewModel(
savedStateHandle: SavedStateHandle = SavedStateHandle(),
state: MigrateToMyItemsState = DEFAULT_STATE,
): MigrateToMyItemsViewModel {
every { savedStateHandle.toMigrateToMyItemsArgs() } returns MigrateToMyItemsArgs(
organizationId = ORGANIZATION_ID,
organizationName = ORGANIZATION_NAME,
)
return MigrateToMyItemsViewModel(
organizationEventManager = mockOrganizationEventManager,
vaultMigrationManager = mockVaultMigrationManager,
vaultSyncManager = mockVaultSyncManager,
savedStateHandle = savedStateHandle,
authRepository = mockAuthRepository,
savedStateHandle = SavedStateHandle(mapOf("state" to state)),
)
}
}
private const val ORGANIZATION_ID = "test-organization-id"
private const val ORGANIZATION_NAME = "Test Organization"
private val DEFAULT_STATE = MigrateToMyItemsState(
organizationId = "test-organization-id",
organizationName = "Test Organization",
dialog = null,
)

View File

@ -99,7 +99,7 @@ internal interface CiphersApi {
@PUT("ciphers/share")
suspend fun bulkShareCiphers(
@Body body: BulkShareCiphersJsonRequest,
): NetworkResult<List<CipherMiniResponseJson>>
): NetworkResult<CipherMiniResponseJson>
/**
* Shares an attachment.

View File

@ -6,13 +6,13 @@ import kotlinx.serialization.Serializable
/**
* Represents a bulk share ciphers request.
*
* @property ciphers The list of ciphers to share.
* @property ciphers The list of ciphers with IDs to share.
* @property collectionIds A list of collection IDs to associate with all ciphers.
*/
@Serializable
data class BulkShareCiphersJsonRequest(
@SerialName("Ciphers")
val ciphers: List<CipherJsonRequest>,
val ciphers: List<CipherWithIdJsonRequest>,
@SerialName("CollectionIds")
val collectionIds: List<String>,

View File

@ -9,58 +9,67 @@ import java.time.ZonedDateTime
* Represents a minimal cipher response from the API, typically returned from bulk operations.
* Contains core cipher metadata without detailed type-specific fields.
*
* @property id The ID of the cipher.
* @property organizationId The organization ID (nullable).
* @property type The type of cipher.
* @property data Serialized cipher data (newer API format).
* @property attachments List of attachments (nullable).
* @property shouldOrganizationUseTotp If the organization should use TOTP.
* @property revisionDate The revision date.
* @property creationDate The creation date.
* @property deletedDate The deleted date (nullable).
* @property reprompt The reprompt type.
* @property key The cipher key (nullable).
* @property archivedDate The archived date (nullable).
* @property cipherMiniResponse The list of mini responses.
*/
@Serializable
data class CipherMiniResponseJson(
@SerialName("id")
val id: String,
@SerialName("organizationId")
val organizationId: String?,
@SerialName("type")
val type: CipherTypeJson,
@SerialName("data")
val data: String?,
val cipherMiniResponse: List<CipherMiniResponse>,
) {
/**
* @property id The ID of the cipher.
* @property organizationId The organization ID (nullable).
* @property type The type of cipher.
* @property data Serialized cipher data (newer API format).
* @property attachments List of attachments (nullable).
* @property shouldOrganizationUseTotp If the organization should use TOTP.
* @property revisionDate The revision date.
* @property creationDate The creation date.
* @property deletedDate The deleted date (nullable).
* @property reprompt The reprompt type.
* @property key The cipher key (nullable).
* @property archivedDate The archived date (nullable).
*/
@Serializable
data class CipherMiniResponse(
@SerialName("id")
val id: String,
@SerialName("attachments")
val attachments: List<SyncResponseJson.Cipher.Attachment>?,
@SerialName("organizationId")
val organizationId: String?,
@SerialName("organizationUseTotp")
val shouldOrganizationUseTotp: Boolean,
@SerialName("type")
val type: CipherTypeJson,
@SerialName("revisionDate")
@Contextual
val revisionDate: ZonedDateTime,
@SerialName("data")
val data: String?,
@SerialName("creationDate")
@Contextual
val creationDate: ZonedDateTime,
@SerialName("attachments")
val attachments: List<SyncResponseJson.Cipher.Attachment>?,
@SerialName("deletedDate")
@Contextual
val deletedDate: ZonedDateTime?,
@SerialName("organizationUseTotp")
val shouldOrganizationUseTotp: Boolean,
@SerialName("reprompt")
val reprompt: CipherRepromptTypeJson,
@SerialName("revisionDate")
@Contextual
val revisionDate: ZonedDateTime,
@SerialName("key")
val key: String?,
@SerialName("creationDate")
@Contextual
val creationDate: ZonedDateTime,
@SerialName("archivedDate")
@Contextual
val archivedDate: ZonedDateTime?,
)
@SerialName("deletedDate")
@Contextual
val deletedDate: ZonedDateTime?,
@SerialName("reprompt")
val reprompt: CipherRepromptTypeJson,
@SerialName("key")
val key: String?,
@SerialName("archivedDate")
@Contextual
val archivedDate: ZonedDateTime?,
)
}

View File

@ -0,0 +1,100 @@
package com.bitwarden.network.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.ZonedDateTime
/**
* Represents a cipher request with an ID, typically used in bulk operations
* where the server needs to identify which cipher is being updated/shared.
* Contains all properties from [CipherJsonRequest] plus an ID field.
*
* @property id The unique identifier of the cipher.
*/
@Serializable
data class CipherWithIdJsonRequest(
@SerialName("Id")
val id: String,
@SerialName("notes")
val notes: String?,
@SerialName("attachments2")
val attachments: Map<String, AttachmentJsonRequest>?,
@SerialName("reprompt")
val reprompt: CipherRepromptTypeJson,
@SerialName("passwordHistory")
val passwordHistory: List<SyncResponseJson.Cipher.PasswordHistory>?,
@SerialName("lastKnownRevisionDate")
@Contextual
val lastKnownRevisionDate: ZonedDateTime?,
@SerialName("type")
val type: CipherTypeJson,
@SerialName("login")
val login: SyncResponseJson.Cipher.Login?,
@SerialName("secureNote")
val secureNote: SyncResponseJson.Cipher.SecureNote?,
@SerialName("sshKey")
val sshKey: SyncResponseJson.Cipher.SshKey?,
@SerialName("folderId")
val folderId: String?,
@SerialName("organizationId")
val organizationId: String?,
@SerialName("identity")
val identity: SyncResponseJson.Cipher.Identity?,
@SerialName("name")
val name: String?,
@SerialName("fields")
val fields: List<SyncResponseJson.Cipher.Field>?,
@SerialName("favorite")
val isFavorite: Boolean,
@SerialName("card")
val card: SyncResponseJson.Cipher.Card?,
@SerialName("key")
val key: String?,
@SerialName("encryptedFor")
val encryptedFor: String?,
)
/**
* Converts a [CipherJsonRequest] and an ID into a [CipherWithIdJsonRequest].
*/
fun CipherJsonRequest.toCipherWithIdJsonRequest(id: String): CipherWithIdJsonRequest =
CipherWithIdJsonRequest(
id = id,
notes = notes,
attachments = attachments,
reprompt = reprompt,
passwordHistory = passwordHistory,
lastKnownRevisionDate = lastKnownRevisionDate,
type = type,
login = login,
secureNote = secureNote,
sshKey = sshKey,
folderId = folderId,
organizationId = organizationId,
identity = identity,
name = name,
fields = fields,
isFavorite = isFavorite,
card = card,
key = key,
encryptedFor = encryptedFor,
)

View File

@ -80,7 +80,7 @@ interface CiphersService {
*/
suspend fun bulkShareCiphers(
body: BulkShareCiphersJsonRequest,
): Result<List<CipherMiniResponseJson>>
): Result<CipherMiniResponseJson>
/**
* Attempt to share an attachment.

View File

@ -201,7 +201,7 @@ internal class CiphersServiceImpl(
override suspend fun bulkShareCiphers(
body: BulkShareCiphersJsonRequest,
): Result<List<CipherMiniResponseJson>> =
): Result<CipherMiniResponseJson> =
ciphersApi
.bulkShareCiphers(body = body)
.toResult()

View File

@ -6,7 +6,6 @@ import com.bitwarden.network.api.CiphersApi
import com.bitwarden.network.base.BaseServiceTest
import com.bitwarden.network.model.AttachmentJsonResponse
import com.bitwarden.network.model.BulkShareCiphersJsonRequest
import com.bitwarden.network.model.CipherMiniResponseJson
import com.bitwarden.network.model.CreateCipherInOrganizationJsonRequest
import com.bitwarden.network.model.CreateCipherResponseJson
import com.bitwarden.network.model.FileUploadType
@ -21,7 +20,8 @@ import com.bitwarden.network.model.createMockAttachmentJsonRequest
import com.bitwarden.network.model.createMockAttachmentResponse
import com.bitwarden.network.model.createMockCipher
import com.bitwarden.network.model.createMockCipherJsonRequest
import com.bitwarden.network.model.createMockCipherMiniResponse
import com.bitwarden.network.model.createMockCipherMiniResponseJson
import com.bitwarden.network.model.toCipherWithIdJsonRequest
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@ -125,7 +125,9 @@ class CiphersServiceTest : BaseServiceTest() {
@Test
fun `createCipherInOrganization should return Invalid with correct data`() =
runTest {
server.enqueue(MockResponse().setResponseCode(400).setBody(CREATE_CIPHER_INVALID_JSON))
server.enqueue(
response = MockResponse().setResponseCode(400).setBody(CREATE_CIPHER_INVALID_JSON),
)
val result = ciphersService.createCipherInOrganization(
body = CreateCipherInOrganizationJsonRequest(
cipher = createMockCipherJsonRequest(number = 1),
@ -158,7 +160,9 @@ class CiphersServiceTest : BaseServiceTest() {
fun `createAttachment with invalid response should return an Invalid with the correct data`() =
runTest {
server.enqueue(
MockResponse().setResponseCode(400).setBody(CREATE_ATTACHMENT_INVALID_JSON),
MockResponse()
.setResponseCode(400)
.setBody(CREATE_ATTACHMENT_INVALID_JSON),
)
val result = ciphersService.createAttachment(
cipherId = "mockId-1",
@ -211,7 +215,9 @@ class CiphersServiceTest : BaseServiceTest() {
@Test
fun `updateCipher with success response should return a Success with the correct cipher`() =
runTest {
server.enqueue(MockResponse().setBody(CREATE_RESTORE_UPDATE_CIPHER_SUCCESS_JSON))
server.enqueue(
response = MockResponse().setBody(CREATE_RESTORE_UPDATE_CIPHER_SUCCESS_JSON),
)
val result = ciphersService.updateCipher(
cipherId = "cipher-id-1",
body = createMockCipherJsonRequest(number = 1),
@ -227,7 +233,9 @@ class CiphersServiceTest : BaseServiceTest() {
@Test
fun `updateCipher with an invalid response should return an Invalid with the correct data`() =
runTest {
server.enqueue(MockResponse().setResponseCode(400).setBody(UPDATE_CIPHER_INVALID_JSON))
server.enqueue(
response = MockResponse().setResponseCode(400).setBody(UPDATE_CIPHER_INVALID_JSON),
)
val result = ciphersService.updateCipher(
cipherId = "cipher-id-1",
body = createMockCipherJsonRequest(number = 1),
@ -310,27 +318,26 @@ class CiphersServiceTest : BaseServiceTest() {
@Test
fun `bulkShareCiphers with success response should return Success`() = runTest {
val expectedCiphers = listOf(
createMockCipherMiniResponse(number = 1),
createMockCipherMiniResponse(number = 2),
)
val expectedResponse = createMockCipherMiniResponseJson(1, 2)
server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(json.encodeToString<List<CipherMiniResponseJson>>(expectedCiphers)),
.setBody(json.encodeToString(expectedResponse)),
)
val result = ciphersService.bulkShareCiphers(
body = BulkShareCiphersJsonRequest(
ciphers = listOf(
createMockCipherJsonRequest(number = 1),
createMockCipherJsonRequest(number = 2),
createMockCipherJsonRequest(number = 1)
.toCipherWithIdJsonRequest(id = "mockId-1"),
createMockCipherJsonRequest(number = 2)
.toCipherWithIdJsonRequest(id = "mockId-2"),
),
collectionIds = listOf("mockId-1"),
),
)
assertEquals(expectedCiphers, result.getOrThrow())
assertEquals(expectedResponse, result.getOrThrow())
}
@Test
@ -343,7 +350,11 @@ class CiphersServiceTest : BaseServiceTest() {
val result = ciphersService.bulkShareCiphers(
body = BulkShareCiphersJsonRequest(
ciphers = listOf(createMockCipherJsonRequest(number = 1)),
ciphers = listOf(
createMockCipherJsonRequest(number = 1).toCipherWithIdJsonRequest(
id = "mockId-1",
),
),
collectionIds = listOf("mockId-1"),
),
)

View File

@ -3,11 +3,11 @@ package com.bitwarden.network.model
import java.time.ZonedDateTime
/**
* Create a mock [CipherMiniResponseJson] for testing.
* Create a mock [CipherMiniResponseJson.CipherMiniResponse] for testing.
*/
fun createMockCipherMiniResponse(
number: Int,
): CipherMiniResponseJson = CipherMiniResponseJson(
): CipherMiniResponseJson.CipherMiniResponse = CipherMiniResponseJson.CipherMiniResponse(
id = "mockId-$number",
organizationId = "mockOrgId-$number",
type = CipherTypeJson.LOGIN,
@ -21,3 +21,12 @@ fun createMockCipherMiniResponse(
key = "mockKey-$number",
archivedDate = null,
)
/**
* Create a mock [CipherMiniResponseJson] wrapper for testing.
*/
fun createMockCipherMiniResponseJson(
vararg numbers: Int,
): CipherMiniResponseJson = CipherMiniResponseJson(
cipherMiniResponse = numbers.map { createMockCipherMiniResponse(it) },
)