diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt index 5e9b9b2f25..cf585c82f0 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt @@ -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 + /** + * Re-encrypts the [cipherViews] with the organizations encryption key into the respective [collectionIds] + */ + suspend fun bulkMoveToOrganization( + userId: String, + organizationId: String, + cipherViews: List, + collectionIds: List, + ): Result> + /** * Validates that the given password matches the password hash. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt index 58c5a14dfe..0072b84d3a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt @@ -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, + collectionIds: List, + ): Result> = runCatchingWithLogs { + getClient(userId = userId) + .vault() + .ciphers() + .prepareCiphersForBulkShare( + organizationId = organizationId, + ciphers = cipherViews, + collectionIds = collectionIds, + ) + } + override suspend fun validatePassword( userId: String, password: String, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManager.kt index 2a4f1d2445..4935f04b7e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManager.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManager.kt @@ -133,4 +133,12 @@ interface CipherManager { cipherView: CipherView, collectionIds: List, ): ShareCipherResult + + /** + * Migrate the attachments if they don't have their own key + */ + suspend fun migrateAttachments( + userId: String, + cipherView: CipherView, + ): Result } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManagerImpl.kt index 36e4991c1d..3dfefad923 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManagerImpl.kt @@ -685,7 +685,7 @@ class CipherManagerImpl( } } - private suspend fun migrateAttachments( + override suspend fun migrateAttachments( userId: String, cipherView: CipherView, ): Result { diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManager.kt index 329ba38c2d..e308e6c108 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManager.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManager.kt @@ -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 + + /** + * 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 } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerImpl.kt index d1469e27c4..53b16468bc 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerImpl.kt @@ -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 { + 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> = 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, + ): Result> = 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, + encryptedCiphersMap: Map, + collectionIds: List, + ): Result { + 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? = + 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() + } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index 343de89294..8eeb682871 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -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, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/MigratePersonalVaultResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/MigratePersonalVaultResult.kt new file mode 100644 index 0000000000..ebfc93b539 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/MigratePersonalVaultResult.kt @@ -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() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt index d58ef7abe7..f6db6231b7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt @@ -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? = 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. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreen.kt index 216f5b69f4..5d3f480fa8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreen.kt @@ -88,6 +88,7 @@ private fun MigrateToMyItemsDialogs( BitwardenBasicDialog( title = dialog.title(), message = dialog.message(), + throwable = dialog.throwable, onDismissRequest = onDismissRequest, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModel.kt index 272e31763f..c4eec5c662 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModel.kt @@ -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( 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() } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt index 6c69d4f26b..1664453145 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt @@ -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(), mockk()) + val collectionIds = listOf("collectionId-1", "collectionId-2") + val expectedResult = listOf(mockk(), mockk()) + + 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()) + val collectionIds = listOf("collectionId-1") + val error = BitwardenException.Decrypt(mockk("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" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerTest.kt index 2fdb29bec0..78ade56aae 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerTest.kt @@ -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>( + 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> { + 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 = emptyList(), + folderViewList: List = emptyList(), + sendViewList: List = 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( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt index 277ad4f055..28e897e2f6 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt @@ -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, + ) + } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreenTest.kt index 4875cf5fc4..d817f92a1b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsScreenTest.kt @@ -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"), ), ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModelTest.kt index 0b83cbfdd5..98500c3eb4 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModelTest.kt @@ -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( + 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, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/api/CiphersApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/CiphersApi.kt index 855958397e..80917eb756 100644 --- a/network/src/main/kotlin/com/bitwarden/network/api/CiphersApi.kt +++ b/network/src/main/kotlin/com/bitwarden/network/api/CiphersApi.kt @@ -99,7 +99,7 @@ internal interface CiphersApi { @PUT("ciphers/share") suspend fun bulkShareCiphers( @Body body: BulkShareCiphersJsonRequest, - ): NetworkResult> + ): NetworkResult /** * Shares an attachment. diff --git a/network/src/main/kotlin/com/bitwarden/network/model/BulkShareCiphersJsonRequest.kt b/network/src/main/kotlin/com/bitwarden/network/model/BulkShareCiphersJsonRequest.kt index e66e925aa4..e36d990dab 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/BulkShareCiphersJsonRequest.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/BulkShareCiphersJsonRequest.kt @@ -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, + val ciphers: List, @SerialName("CollectionIds") val collectionIds: List, diff --git a/network/src/main/kotlin/com/bitwarden/network/model/CipherMiniResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/CipherMiniResponseJson.kt index b19866c716..25ce821613 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/CipherMiniResponseJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/CipherMiniResponseJson.kt @@ -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, +) { + /** + * @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?, + @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?, - @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?, + ) +} diff --git a/network/src/main/kotlin/com/bitwarden/network/model/CipherWithIdJsonRequest.kt b/network/src/main/kotlin/com/bitwarden/network/model/CipherWithIdJsonRequest.kt new file mode 100644 index 0000000000..6649900331 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/CipherWithIdJsonRequest.kt @@ -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?, + + @SerialName("reprompt") + val reprompt: CipherRepromptTypeJson, + + @SerialName("passwordHistory") + val passwordHistory: List?, + + @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?, + + @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, + ) diff --git a/network/src/main/kotlin/com/bitwarden/network/service/CiphersService.kt b/network/src/main/kotlin/com/bitwarden/network/service/CiphersService.kt index 25f392cb11..083a259c40 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/CiphersService.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/CiphersService.kt @@ -80,7 +80,7 @@ interface CiphersService { */ suspend fun bulkShareCiphers( body: BulkShareCiphersJsonRequest, - ): Result> + ): Result /** * Attempt to share an attachment. diff --git a/network/src/main/kotlin/com/bitwarden/network/service/CiphersServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/CiphersServiceImpl.kt index 01263ee837..7ab8a40e58 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/CiphersServiceImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/CiphersServiceImpl.kt @@ -201,7 +201,7 @@ internal class CiphersServiceImpl( override suspend fun bulkShareCiphers( body: BulkShareCiphersJsonRequest, - ): Result> = + ): Result = ciphersApi .bulkShareCiphers(body = body) .toResult() diff --git a/network/src/test/kotlin/com/bitwarden/network/service/CiphersServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/CiphersServiceTest.kt index 373fc515fe..1b302aeac5 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/CiphersServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/CiphersServiceTest.kt @@ -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>(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"), ), ) diff --git a/network/src/test/kotlin/com/bitwarden/network/model/CipherMiniResponseJsonUtil.kt b/network/src/testFixtures/kotlin/com/bitwarden/network/model/CipherMiniResponseJsonUtil.kt similarity index 62% rename from network/src/test/kotlin/com/bitwarden/network/model/CipherMiniResponseJsonUtil.kt rename to network/src/testFixtures/kotlin/com/bitwarden/network/model/CipherMiniResponseJsonUtil.kt index e46a8d7823..b87b08c942 100644 --- a/network/src/test/kotlin/com/bitwarden/network/model/CipherMiniResponseJsonUtil.kt +++ b/network/src/testFixtures/kotlin/com/bitwarden/network/model/CipherMiniResponseJsonUtil.kt @@ -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) }, +)