mirror of
https://github.com/bitwarden/android.git
synced 2026-02-04 03:05:28 -06:00
[PM-28471] Migrate individual vault to organization (#6352)
This commit is contained in:
parent
759e0563a9
commit
eb18ca04a0
@ -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.
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -685,7 +685,7 @@ class CipherManagerImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun migrateAttachments(
|
||||
override suspend fun migrateAttachments(
|
||||
userId: String,
|
||||
cipherView: CipherView,
|
||||
): Result<CipherView> {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -88,6 +88,7 @@ private fun MigrateToMyItemsDialogs(
|
||||
BitwardenBasicDialog(
|
||||
title = dialog.title(),
|
||||
message = dialog.message(),
|
||||
throwable = dialog.throwable,
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -99,7 +99,7 @@ internal interface CiphersApi {
|
||||
@PUT("ciphers/share")
|
||||
suspend fun bulkShareCiphers(
|
||||
@Body body: BulkShareCiphersJsonRequest,
|
||||
): NetworkResult<List<CipherMiniResponseJson>>
|
||||
): NetworkResult<CipherMiniResponseJson>
|
||||
|
||||
/**
|
||||
* Shares an attachment.
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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?,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -80,7 +80,7 @@ interface CiphersService {
|
||||
*/
|
||||
suspend fun bulkShareCiphers(
|
||||
body: BulkShareCiphersJsonRequest,
|
||||
): Result<List<CipherMiniResponseJson>>
|
||||
): Result<CipherMiniResponseJson>
|
||||
|
||||
/**
|
||||
* Attempt to share an attachment.
|
||||
|
||||
@ -201,7 +201,7 @@ internal class CiphersServiceImpl(
|
||||
|
||||
override suspend fun bulkShareCiphers(
|
||||
body: BulkShareCiphersJsonRequest,
|
||||
): Result<List<CipherMiniResponseJson>> =
|
||||
): Result<CipherMiniResponseJson> =
|
||||
ciphersApi
|
||||
.bulkShareCiphers(body = body)
|
||||
.toResult()
|
||||
|
||||
@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
@ -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) },
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user