diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/FolderManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/FolderManager.kt new file mode 100644 index 0000000000..f9a03f3460 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/FolderManager.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.data.vault.manager + +import com.bitwarden.vault.FolderView +import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult +import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult +import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult + +/** + * Manages the creating, updating, and deleting folders. + */ +interface FolderManager { + /** + * Attempt to create a folder. + */ + suspend fun createFolder(folderView: FolderView): CreateFolderResult + + /** + * Attempt to delete a folder. + */ + suspend fun deleteFolder(folderId: String): DeleteFolderResult + + /** + * Attempt to update a folder. + */ + suspend fun updateFolder(folderId: String, folderView: FolderView): UpdateFolderResult +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/FolderManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/FolderManagerImpl.kt new file mode 100644 index 0000000000..a0d7e30de4 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/FolderManagerImpl.kt @@ -0,0 +1,170 @@ +package com.x8bit.bitwarden.data.vault.manager + +import com.bitwarden.core.data.util.flatMap +import com.bitwarden.data.manager.DispatcherManager +import com.bitwarden.network.model.UpdateFolderResponseJson +import com.bitwarden.network.service.FolderService +import com.bitwarden.vault.FolderView +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.platform.error.NoActiveUserException +import com.x8bit.bitwarden.data.platform.manager.PushManager +import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData +import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData +import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource +import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult +import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult +import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult +import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkFolder +import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * The default implementation of the [FolderManager]. + */ +class FolderManagerImpl( + private val authDiskSource: AuthDiskSource, + private val folderService: FolderService, + private val vaultDiskSource: VaultDiskSource, + private val vaultSdkSource: VaultSdkSource, + dispatcherManager: DispatcherManager, + pushManager: PushManager, +) : FolderManager { + private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) + private val ioScope = CoroutineScope(dispatcherManager.io) + private val activeUserId: String? get() = authDiskSource.userState?.activeUserId + + init { + pushManager + .syncFolderDeleteFlow + .onEach(::deleteFolder) + .launchIn(unconfinedScope) + + pushManager + .syncFolderUpsertFlow + .onEach(::syncFolderIfNecessary) + .launchIn(ioScope) + } + + override suspend fun createFolder(folderView: FolderView): CreateFolderResult { + val userId = activeUserId ?: return CreateFolderResult.Error(NoActiveUserException()) + return vaultSdkSource + .encryptFolder(userId = userId, folder = folderView) + .flatMap { folderService.createFolder(body = it.toEncryptedNetworkFolder()) } + .onSuccess { vaultDiskSource.saveFolder(userId = userId, folder = it) } + .flatMap { + vaultSdkSource.decryptFolder(userId = userId, folder = it.toEncryptedSdkFolder()) + } + .fold( + onSuccess = { CreateFolderResult.Success(folderView = it) }, + onFailure = { CreateFolderResult.Error(error = it) }, + ) + } + + override suspend fun deleteFolder(folderId: String): DeleteFolderResult { + val userId = activeUserId ?: return DeleteFolderResult.Error(NoActiveUserException()) + return folderService + .deleteFolder(folderId = folderId) + .onSuccess { + clearFolderIdFromCiphers(userId = userId, folderId = folderId) + vaultDiskSource.deleteFolder(userId = userId, folderId = folderId) + } + .fold( + onSuccess = { DeleteFolderResult.Success }, + onFailure = { DeleteFolderResult.Error(error = it) }, + ) + } + + override suspend fun updateFolder( + folderId: String, + folderView: FolderView, + ): UpdateFolderResult { + val userId = activeUserId ?: return UpdateFolderResult.Error( + errorMessage = null, + error = NoActiveUserException(), + ) + return vaultSdkSource + .encryptFolder(userId = userId, folder = folderView) + .flatMap { folder -> + folderService.updateFolder( + folderId = folder.id.toString(), + body = folder.toEncryptedNetworkFolder(), + ) + } + .fold( + onSuccess = { response -> + when (response) { + is UpdateFolderResponseJson.Success -> { + vaultDiskSource.saveFolder(userId = userId, folder = response.folder) + vaultSdkSource + .decryptFolder( + userId = userId, + folder = response.folder.toEncryptedSdkFolder(), + ) + .fold( + onSuccess = { UpdateFolderResult.Success(it) }, + onFailure = { + UpdateFolderResult.Error(errorMessage = null, error = it) + }, + ) + } + + is UpdateFolderResponseJson.Invalid -> { + UpdateFolderResult.Error(errorMessage = response.message, error = null) + } + } + }, + onFailure = { UpdateFolderResult.Error(it.message, error = it) }, + ) + } + + private suspend fun clearFolderIdFromCiphers(userId: String, folderId: String) { + vaultDiskSource.getCiphers(userId = userId).forEach { + if (it.folderId == folderId) { + vaultDiskSource.saveCipher(userId = userId, cipher = it.copy(folderId = null)) + } + } + } + + /** + * Deletes the folder specified by [syncFolderDeleteData] from disk. + */ + private suspend fun deleteFolder(syncFolderDeleteData: SyncFolderDeleteData) { + clearFolderIdFromCiphers( + folderId = syncFolderDeleteData.folderId, + userId = syncFolderDeleteData.userId, + ) + vaultDiskSource.deleteFolder( + folderId = syncFolderDeleteData.folderId, + userId = syncFolderDeleteData.userId, + ) + } + + /** + * Syncs an individual folder contained in [syncFolderUpsertData] to disk if certain criteria + * are met. + */ + private suspend fun syncFolderIfNecessary(syncFolderUpsertData: SyncFolderUpsertData) { + val userId = activeUserId ?: return + val folderId = syncFolderUpsertData.folderId + val isUpdate = syncFolderUpsertData.isUpdate + val revisionDate = syncFolderUpsertData.revisionDate + val localFolder = vaultDiskSource + .getFolders(userId = userId) + .first() + .find { it.id == folderId } + val isValidCreate = !isUpdate && localFolder == null + val isValidUpdate = isUpdate && + localFolder != null && + localFolder.revisionDate.toEpochSecond() < revisionDate.toEpochSecond() + + if (!isValidCreate && !isValidUpdate) return + + folderService + .getFolder(folderId = folderId) + .onSuccess { vaultDiskSource.saveFolder(userId = userId, folder = it) } + } +} 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 465548aa3c..0935961c98 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 @@ -5,8 +5,9 @@ import com.bitwarden.core.data.manager.realtime.RealtimeManager import com.bitwarden.data.manager.DispatcherManager import com.bitwarden.network.service.CiphersService import com.bitwarden.network.service.DownloadService -import com.bitwarden.network.service.SyncService +import com.bitwarden.network.service.FolderService import com.bitwarden.network.service.SendsService +import com.bitwarden.network.service.SyncService import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager @@ -24,6 +25,8 @@ import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManagerImpl import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.manager.FileManagerImpl +import com.x8bit.bitwarden.data.vault.manager.FolderManager +import com.x8bit.bitwarden.data.vault.manager.FolderManagerImpl import com.x8bit.bitwarden.data.vault.manager.SendManager import com.x8bit.bitwarden.data.vault.manager.SendManagerImpl import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager @@ -71,6 +74,24 @@ object VaultManagerModule { pushManager = pushManager, ) + @Provides + @Singleton + fun provideFolderManager( + folderService: FolderService, + vaultDiskSource: VaultDiskSource, + vaultSdkSource: VaultSdkSource, + authDiskSource: AuthDiskSource, + dispatcherManager: DispatcherManager, + pushManager: PushManager, + ): FolderManager = FolderManagerImpl( + authDiskSource = authDiskSource, + folderService = folderService, + vaultDiskSource = vaultDiskSource, + vaultSdkSource = vaultSdkSource, + dispatcherManager = dispatcherManager, + pushManager = pushManager, + ) + @Provides @Singleton fun provideSendManager( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index 8401e3e0fc..d98cac0b22 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -13,19 +13,17 @@ import com.bitwarden.vault.CipherView import com.bitwarden.vault.DecryptCipherListResult import com.bitwarden.vault.FolderView import com.x8bit.bitwarden.data.vault.manager.CipherManager +import com.x8bit.bitwarden.data.vault.manager.FolderManager import com.x8bit.bitwarden.data.vault.manager.SendManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManager import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem -import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult -import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult import com.x8bit.bitwarden.data.vault.repository.model.DomainsData import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult -import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType @@ -37,7 +35,7 @@ import javax.crypto.Cipher * Responsible for managing vault data inside the network layer. */ @Suppress("TooManyFunctions") -interface VaultRepository : CipherManager, SendManager, VaultLockManager { +interface VaultRepository : CipherManager, FolderManager, SendManager, VaultLockManager { /** * The [VaultFilterType] for the current user. @@ -204,21 +202,6 @@ interface VaultRepository : CipherManager, SendManager, VaultLockManager { */ suspend fun generateTotp(cipherId: String, time: DateTime): GenerateTotpResult - /** - * Attempt to create a folder. - */ - suspend fun createFolder(folderView: FolderView): CreateFolderResult - - /** - * Attempt to delete a folder. - */ - suspend fun deleteFolder(folderId: String): DeleteFolderResult - - /** - * Attempt to update a folder. - */ - suspend fun updateFolder(folderId: String, folderView: FolderView): UpdateFolderResult - /** * Attempt to get the user's vault data for export. * diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index ad47621f87..58461185ad 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -10,12 +10,9 @@ import com.bitwarden.core.data.repository.util.map import com.bitwarden.core.data.repository.util.mapNullable import com.bitwarden.core.data.repository.util.updateToPendingOrLoading import com.bitwarden.core.data.util.asFailure -import com.bitwarden.core.data.util.flatMap import com.bitwarden.data.manager.DispatcherManager import com.bitwarden.exporters.ExportFormat import com.bitwarden.fido.Fido2CredentialAutofillView -import com.bitwarden.network.model.UpdateFolderResponseJson -import com.bitwarden.network.service.FolderService import com.bitwarden.network.util.isNoConnectionError import com.bitwarden.sdk.Fido2CredentialStore import com.bitwarden.send.SendView @@ -34,14 +31,13 @@ import com.x8bit.bitwarden.data.platform.error.MissingPropertyException import com.x8bit.bitwarden.data.platform.error.NoActiveUserException import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager import com.x8bit.bitwarden.data.platform.manager.PushManager -import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData -import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndLoggedIn 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.CipherManager import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager +import com.x8bit.bitwarden.data.vault.manager.FolderManager import com.x8bit.bitwarden.data.vault.manager.SendManager import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManager @@ -50,21 +46,17 @@ import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult import com.x8bit.bitwarden.data.vault.manager.model.ImportCxfPayloadResult import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem -import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult -import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult import com.x8bit.bitwarden.data.vault.repository.model.DomainsData import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult -import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import com.x8bit.bitwarden.data.vault.repository.util.sortAlphabetically import com.x8bit.bitwarden.data.vault.repository.util.sortAlphabeticallyByTypeAndOrganization import com.x8bit.bitwarden.data.vault.repository.util.toDomainsData -import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkFolder import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollectionList @@ -87,7 +79,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -116,12 +107,12 @@ private const val STOP_TIMEOUT_DELAY_MS: Long = 1000L */ @Suppress("TooManyFunctions", "LongParameterList", "LargeClass") class VaultRepositoryImpl( - private val folderService: FolderService, private val vaultDiskSource: VaultDiskSource, private val vaultSdkSource: VaultSdkSource, private val authDiskSource: AuthDiskSource, private val settingsDiskSource: SettingsDiskSource, private val cipherManager: CipherManager, + private val folderManager: FolderManager, private val sendManager: SendManager, private val vaultLockManager: VaultLockManager, private val totpCodeManager: TotpCodeManager, @@ -133,6 +124,7 @@ class VaultRepositoryImpl( private val credentialExchangeImportManager: CredentialExchangeImportManager, ) : VaultRepository, CipherManager by cipherManager, + FolderManager by folderManager, SendManager by sendManager, VaultLockManager by vaultLockManager { @@ -281,16 +273,6 @@ class VaultRepositoryImpl( .onEach { sync(forced = false) } .launchIn(unconfinedScope) - pushManager - .syncFolderDeleteFlow - .onEach(::deleteFolder) - .launchIn(unconfinedScope) - - pushManager - .syncFolderUpsertFlow - .onEach(::syncFolderIfNecessary) - .launchIn(ioScope) - databaseSchemeManager .databaseSchemeChangeFlow .onEach { sync(forced = true) } @@ -645,106 +627,6 @@ class VaultRepositoryImpl( ) } - override suspend fun createFolder(folderView: FolderView): CreateFolderResult { - val userId = activeUserId - ?: return CreateFolderResult.Error(error = NoActiveUserException()) - return vaultSdkSource - .encryptFolder( - userId = userId, - folder = folderView, - ) - .flatMap { folder -> - folderService - .createFolder( - body = folder.toEncryptedNetworkFolder(), - ) - } - .onSuccess { vaultDiskSource.saveFolder(userId = userId, folder = it) } - .flatMap { vaultSdkSource.decryptFolder(userId, it.toEncryptedSdkFolder()) } - .fold( - onSuccess = { CreateFolderResult.Success(folderView = it) }, - onFailure = { CreateFolderResult.Error(error = it) }, - ) - } - - override suspend fun updateFolder( - folderId: String, - folderView: FolderView, - ): UpdateFolderResult { - val userId = activeUserId ?: return UpdateFolderResult.Error( - errorMessage = null, - error = NoActiveUserException(), - ) - return vaultSdkSource - .encryptFolder( - userId = userId, - folder = folderView, - ) - .flatMap { folder -> - folderService - .updateFolder( - folderId = folder.id.toString(), - body = folder.toEncryptedNetworkFolder(), - ) - } - .fold( - onSuccess = { response -> - when (response) { - is UpdateFolderResponseJson.Success -> { - vaultDiskSource.saveFolder(userId, response.folder) - vaultSdkSource - .decryptFolder( - userId, - response.folder.toEncryptedSdkFolder(), - ) - .fold( - onSuccess = { UpdateFolderResult.Success(it) }, - onFailure = { - UpdateFolderResult.Error( - errorMessage = null, - error = it, - ) - }, - ) - } - - is UpdateFolderResponseJson.Invalid -> { - UpdateFolderResult.Error( - errorMessage = response.message, - error = null, - ) - } - } - }, - onFailure = { UpdateFolderResult.Error(it.message, error = it) }, - ) - } - - override suspend fun deleteFolder(folderId: String): DeleteFolderResult { - val userId = activeUserId - ?: return DeleteFolderResult.Error(error = NoActiveUserException()) - return folderService - .deleteFolder( - folderId = folderId, - ) - .onSuccess { - clearFolderIdFromCiphers(folderId, userId) - vaultDiskSource.deleteFolder(userId, folderId) - } - .fold( - onSuccess = { DeleteFolderResult.Success }, - onFailure = { DeleteFolderResult.Error(error = it) }, - ) - } - - private suspend fun clearFolderIdFromCiphers(folderId: String, userId: String) { - vaultDiskSource.getCiphers(userId = userId).forEach { - if (it.folderId == folderId) { - vaultDiskSource.saveCipher(userId = userId, cipher = it.copy(folderId = null)) - } - } - } - override suspend fun exportVaultDataToString( format: ExportFormat, restrictedTypes: List, @@ -1049,48 +931,6 @@ class VaultRepositoryImpl( } ?: DataState.Loading - //region Push notification helpers - - /** - * Deletes the folder specified by [syncFolderDeleteData] from disk. - */ - private suspend fun deleteFolder(syncFolderDeleteData: SyncFolderDeleteData) { - clearFolderIdFromCiphers( - folderId = syncFolderDeleteData.folderId, - userId = syncFolderDeleteData.userId, - ) - vaultDiskSource.deleteFolder( - folderId = syncFolderDeleteData.folderId, - userId = syncFolderDeleteData.userId, - ) - } - - /** - * Syncs an individual folder contained in [syncFolderUpsertData] to disk if certain criteria - * are met. - */ - private suspend fun syncFolderIfNecessary(syncFolderUpsertData: SyncFolderUpsertData) { - val userId = activeUserId ?: return - val folderId = syncFolderUpsertData.folderId - val isUpdate = syncFolderUpsertData.isUpdate - val revisionDate = syncFolderUpsertData.revisionDate - val localFolder = vaultDiskSource - .getFolders(userId = userId) - .first() - .find { it.id == folderId } - val isValidCreate = !isUpdate && localFolder == null - val isValidUpdate = isUpdate && - localFolder != null && - localFolder.revisionDate.toEpochSecond() < revisionDate.toEpochSecond() - - if (!isValidCreate && !isValidUpdate) return - - folderService - .getFolder(folderId = folderId) - .onSuccess { vaultDiskSource.saveFolder(userId = userId, folder = it) } - } - //endregion Push Notification helpers - private suspend fun syncInternal( userId: String, forced: Boolean, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt index 9743bc7d00..3f0e532400 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt @@ -1,7 +1,6 @@ package com.x8bit.bitwarden.data.vault.repository.di import com.bitwarden.data.manager.DispatcherManager -import com.bitwarden.network.service.FolderService 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.DatabaseSchemeManager @@ -10,6 +9,7 @@ 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.CipherManager import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager +import com.x8bit.bitwarden.data.vault.manager.FolderManager import com.x8bit.bitwarden.data.vault.manager.SendManager import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManager @@ -33,12 +33,12 @@ object VaultRepositoryModule { @Provides @Singleton fun providesVaultRepository( - folderService: FolderService, vaultDiskSource: VaultDiskSource, vaultSdkSource: VaultSdkSource, authDiskSource: AuthDiskSource, settingsDiskSource: SettingsDiskSource, cipherManager: CipherManager, + folderManager: FolderManager, sendManager: SendManager, vaultLockManager: VaultLockManager, dispatcherManager: DispatcherManager, @@ -49,12 +49,12 @@ object VaultRepositoryModule { vaultSyncManager: VaultSyncManager, credentialExchangeImportManager: CredentialExchangeImportManager, ): VaultRepository = VaultRepositoryImpl( - folderService = folderService, vaultDiskSource = vaultDiskSource, vaultSdkSource = vaultSdkSource, authDiskSource = authDiskSource, settingsDiskSource = settingsDiskSource, cipherManager = cipherManager, + folderManager = folderManager, sendManager = sendManager, vaultLockManager = vaultLockManager, dispatcherManager = dispatcherManager, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/FolderManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/FolderManagerTest.kt new file mode 100644 index 0000000000..37070ffc33 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/FolderManagerTest.kt @@ -0,0 +1,610 @@ +package com.x8bit.bitwarden.data.vault.manager + +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.bitwarden.core.data.util.asFailure +import com.bitwarden.core.data.util.asSuccess +import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager +import com.bitwarden.network.model.FolderJsonRequest +import com.bitwarden.network.model.SyncResponseJson +import com.bitwarden.network.model.UpdateFolderResponseJson +import com.bitwarden.network.model.createMockCipher +import com.bitwarden.network.model.createMockFolder +import com.bitwarden.network.service.FolderService +import com.bitwarden.vault.Folder +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 +import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource +import com.x8bit.bitwarden.data.platform.error.NoActiveUserException +import com.x8bit.bitwarden.data.platform.manager.PushManager +import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData +import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData +import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource +import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult +import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult +import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult +import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.runs +import io.mockk.unmockkConstructor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit + +class FolderManagerTest { + private val fakeAuthDiskSource = FakeAuthDiskSource() + private val folderService = mockk() + private val vaultDiskSource = mockk() + private val vaultSdkSource = mockk() + private val mutableSyncFolderDeleteFlow = bufferedMutableSharedFlow() + private val mutableSyncFolderUpsertFlow = bufferedMutableSharedFlow() + private val pushManager: PushManager = mockk { + every { syncFolderDeleteFlow } returns mutableSyncFolderDeleteFlow + every { syncFolderUpsertFlow } returns mutableSyncFolderUpsertFlow + } + + private val folderManager: FolderManager = FolderManagerImpl( + authDiskSource = fakeAuthDiskSource, + folderService = folderService, + vaultDiskSource = vaultDiskSource, + vaultSdkSource = vaultSdkSource, + pushManager = pushManager, + dispatcherManager = FakeDispatcherManager(), + ) + + @BeforeEach + fun setup() { + mockkConstructor(NoActiveUserException::class) + every { + anyConstructed() == any() + } returns true + } + + @AfterEach + fun tearDown() { + unmockkConstructor(NoActiveUserException::class) + } + + @Test + fun `createFolder with no active user should return CreateFolderResult failure`() = + runTest { + fakeAuthDiskSource.userState = null + + val result = folderManager.createFolder(folderView = mockk()) + + assertEquals(CreateFolderResult.Error(error = NoActiveUserException()), result) + } + + @Suppress("MaxLineLength") + @Test + fun `createFolder with folderService Delete failure should return DeleteFolderResult Failure`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val folderId = "mockId-1" + val error = Throwable("fail") + coEvery { folderService.deleteFolder(folderId = folderId) } returns error.asFailure() + + val result = folderManager.deleteFolder(folderId = folderId) + + assertEquals(DeleteFolderResult.Error(error = error), result) + } + + @Test + fun `createFolder with encryptFolder failure should return CreateFolderResult failure`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val folderView = FolderView( + id = null, + name = "TestName", + revisionDate = Instant.now(FIXED_CLOCK), + ) + val error = IllegalStateException() + + coEvery { + vaultSdkSource.encryptFolder(userId = ACTIVE_USER_ID, folder = folderView) + } returns error.asFailure() + + val result = folderManager.createFolder(folderView = folderView) + assertEquals(CreateFolderResult.Error(error = error), result) + } + + @Test + fun `createFolder with folderService failure should return CreateFolderResult failure`() = + runTest { + val date = Instant.now(FIXED_CLOCK) + val testFolderName = "TestName" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val folderView = FolderView( + id = null, + name = testFolderName, + revisionDate = date, + ) + val error = IllegalStateException() + + coEvery { + vaultSdkSource.encryptFolder(userId = ACTIVE_USER_ID, folder = folderView) + } returns Folder(id = null, name = testFolderName, revisionDate = date).asSuccess() + + coEvery { + folderService.createFolder(body = FolderJsonRequest(name = testFolderName)) + } returns error.asFailure() + + val result = folderManager.createFolder(folderView = folderView) + assertEquals(CreateFolderResult.Error(error = error), result) + } + + @Test + fun `createFolder with folderService createFolder should return CreateFolderResult success`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val date = Instant.now(FIXED_CLOCK) + val testFolderName = "TestName" + val folderView = FolderView( + id = null, + name = testFolderName, + revisionDate = date, + ) + val networkFolder = SyncResponseJson.Folder( + id = "1", + name = testFolderName, + revisionDate = ZonedDateTime.now(FIXED_CLOCK), + ) + + coEvery { + vaultSdkSource.encryptFolder(userId = ACTIVE_USER_ID, folder = folderView) + } returns Folder(id = null, name = testFolderName, revisionDate = date).asSuccess() + + coEvery { + folderService.createFolder(body = FolderJsonRequest(name = testFolderName)) + } returns networkFolder.asSuccess() + + coEvery { + vaultDiskSource.saveFolder(userId = ACTIVE_USER_ID, folder = networkFolder) + } just runs + + coEvery { + vaultSdkSource.decryptFolder( + userId = ACTIVE_USER_ID, + folder = networkFolder.toEncryptedSdkFolder(), + ) + } returns folderView.asSuccess() + + val result = folderManager.createFolder(folderView = folderView) + + assertEquals(CreateFolderResult.Success(folderView = folderView), result) + } + + @Test + fun `deleteFolder with no active user should return DeleteFolderResult failure`() = + runTest { + fakeAuthDiskSource.userState = null + + val result = folderManager.deleteFolder(folderId = "Test") + + assertEquals(DeleteFolderResult.Error(error = NoActiveUserException()), result) + } + + @Suppress("MaxLineLength") + @Test + fun `DeleteFolder with folderService Delete failure should return DeleteFolderResult Failure`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val error = Throwable("fail") + val folderId = "mockId-1" + coEvery { folderService.deleteFolder(folderId = folderId) } returns error.asFailure() + + val result = folderManager.deleteFolder(folderId = folderId) + + assertEquals(DeleteFolderResult.Error(error = error), result) + } + + @Suppress("MaxLineLength") + @Test + fun `DeleteFolder with folderService Delete success should return DeleteFolderResult Success and update ciphers`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = ACTIVE_USER_ID + val folderId = "mockFolderId-1" + val mockCipher = createMockCipher(number = 1) + val ciphers = listOf(mockCipher, createMockCipher(number = 2)) + coEvery { folderService.deleteFolder(folderId = folderId) } returns Unit.asSuccess() + coEvery { vaultDiskSource.deleteFolder(userId = userId, folderId = folderId) } just runs + coEvery { vaultDiskSource.getCiphers(userId = userId) } returns ciphers + coEvery { + vaultDiskSource.saveCipher( + userId = userId, + cipher = mockCipher.copy(folderId = null), + ) + } just runs + + val result = folderManager.deleteFolder(folderId = folderId) + + coVerify(exactly = 1) { + vaultDiskSource.saveCipher( + userId = userId, + cipher = mockCipher.copy(folderId = null), + ) + } + + assertEquals(DeleteFolderResult.Success, result) + } + + @Test + fun `updateFolder with no active user should return UpdateFolderResult failure`() = + runTest { + fakeAuthDiskSource.userState = null + + val result = folderManager.updateFolder(folderId = "Test", folderView = mockk()) + + assertEquals( + UpdateFolderResult.Error(errorMessage = null, error = NoActiveUserException()), + result, + ) + } + + @Test + fun `updateFolder with encryptFolder failure should return UpdateFolderResult failure`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val folderId = "testId" + val folderView = FolderView( + id = folderId, + name = "TestName", + revisionDate = Instant.now(FIXED_CLOCK), + ) + val error = IllegalStateException() + + coEvery { + vaultSdkSource.encryptFolder(userId = ACTIVE_USER_ID, folder = folderView) + } returns error.asFailure() + + val result = folderManager.updateFolder(folderId = folderId, folderView = folderView) + + assertEquals(UpdateFolderResult.Error(errorMessage = null, error = error), result) + } + + @Test + fun `updateFolder with folderService failure should return UpdateFolderResult failure`() = + runTest { + val date = Instant.now(FIXED_CLOCK) + val testFolderName = "TestName" + val folderId = "testId" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val folderView = FolderView( + id = folderId, + name = testFolderName, + revisionDate = date, + ) + val error = IllegalStateException() + + coEvery { + vaultSdkSource.encryptFolder(userId = ACTIVE_USER_ID, folder = folderView) + } returns Folder(id = folderId, name = testFolderName, revisionDate = date).asSuccess() + + coEvery { + folderService.updateFolder( + folderId = folderId, + body = FolderJsonRequest(name = testFolderName), + ) + } returns error.asFailure() + + val result = folderManager.updateFolder(folderId = folderId, folderView = folderView) + assertEquals(UpdateFolderResult.Error(errorMessage = null, error = error), result) + } + + @Suppress("MaxLineLength") + @Test + fun `updateFolder with folderService updateFolder Invalid response should return UpdateFolderResult Error with a non-null message`() = + runTest { + val date = Instant.now(FIXED_CLOCK) + val testFolderName = "TestName" + val folderId = "testId" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val folderView = FolderView( + id = folderId, + name = testFolderName, + revisionDate = date, + ) + + coEvery { + vaultSdkSource.encryptFolder(userId = ACTIVE_USER_ID, folder = folderView) + } returns Folder(id = folderId, name = testFolderName, revisionDate = date).asSuccess() + + coEvery { + folderService.updateFolder( + folderId = folderId, + body = FolderJsonRequest(name = testFolderName), + ) + } returns UpdateFolderResponseJson + .Invalid( + message = "You do not have permission to edit this.", + validationErrors = null, + ) + .asSuccess() + + val result = folderManager.updateFolder(folderId = folderId, folderView = folderView) + assertEquals( + UpdateFolderResult.Error( + errorMessage = "You do not have permission to edit this.", + error = null, + ), + result, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `updateFolder with folderService updateFolder success should return UpdateFolderResult success`() = + runTest { + val date = Instant.now(FIXED_CLOCK) + val testFolderName = "TestName" + val folderId = "testId" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val folderView = FolderView( + id = folderId, + name = testFolderName, + revisionDate = date, + ) + val networkFolder = SyncResponseJson.Folder( + id = "1", + name = testFolderName, + revisionDate = ZonedDateTime.now(FIXED_CLOCK), + ) + + coEvery { + vaultSdkSource.encryptFolder(userId = ACTIVE_USER_ID, folder = folderView) + } returns Folder(id = folderId, name = testFolderName, revisionDate = date).asSuccess() + + coEvery { + folderService.updateFolder( + folderId = folderId, + body = FolderJsonRequest(name = testFolderName), + ) + } returns UpdateFolderResponseJson + .Success(folder = networkFolder) + .asSuccess() + + coEvery { + vaultDiskSource.saveFolder(userId = ACTIVE_USER_ID, folder = networkFolder) + } just runs + + coEvery { + vaultSdkSource.decryptFolder( + userId = ACTIVE_USER_ID, + folder = networkFolder.toEncryptedSdkFolder(), + ) + } returns folderView.asSuccess() + + val result = folderManager.updateFolder(folderId = folderId, folderView = folderView) + assertEquals(UpdateFolderResult.Success(folderView = folderView), result) + } + + @Test + fun `syncFolderDeleteFlow should delete folder from disk and update ciphers`() { + val userId = "mockId-1" + val folderId = "mockId-1" + val cipher = createMockCipher(number = 1, folderId = folderId) + val updatedCipher = createMockCipher(number = 1, folderId = null) + + coEvery { vaultDiskSource.deleteFolder(userId = userId, folderId = folderId) } just runs + coEvery { vaultDiskSource.getCiphers(userId = userId) } returns listOf(cipher) + coEvery { vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher) } just runs + + mutableSyncFolderDeleteFlow.tryEmit( + SyncFolderDeleteData(userId = userId, folderId = folderId), + ) + + coVerify(exactly = 1) { + vaultDiskSource.deleteFolder(userId = userId, folderId = folderId) + vaultDiskSource.getCiphers(userId = userId) + vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher) + } + } + + @Test + fun `syncFolderUpsertFlow create with local folder should do nothing`() = runTest { + val number = 1 + val userId = ACTIVE_USER_ID + val folderId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val folder = createMockFolder(number = number) + coEvery { + vaultDiskSource.getFolders(userId = userId) + } returns MutableStateFlow(listOf(folder)) + + mutableSyncFolderUpsertFlow.tryEmit( + SyncFolderUpsertData( + folderId = folderId, + revisionDate = ZonedDateTime.now(FIXED_CLOCK), + isUpdate = false, + ), + ) + + coVerify(exactly = 0) { + folderService.getFolder(folderId = any()) + vaultDiskSource.saveFolder(userId = any(), folder = any()) + } + } + + @Test + fun `syncFolderUpsertFlow update with no local folder should do nothing`() = runTest { + val number = 1 + val userId = ACTIVE_USER_ID + val folderId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { + vaultDiskSource.getFolders(userId = userId) + } returns MutableStateFlow(emptyList()) + + mutableSyncFolderUpsertFlow.tryEmit( + SyncFolderUpsertData( + folderId = folderId, + revisionDate = ZonedDateTime.now(FIXED_CLOCK), + isUpdate = true, + ), + ) + + coVerify(exactly = 0) { + folderService.getFolder(folderId = any()) + vaultDiskSource.saveFolder(userId = any(), folder = any()) + } + } + + @Test + fun `syncFolderUpsertFlow update with more recent local folder should do nothing`() = runTest { + val number = 1 + val userId = ACTIVE_USER_ID + val folderId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val folder = createMockFolder(number = number) + coEvery { + vaultDiskSource.getFolders(userId = userId) + } returns MutableStateFlow(listOf(folder)) + + mutableSyncFolderUpsertFlow.tryEmit( + SyncFolderUpsertData( + folderId = folderId, + revisionDate = ZonedDateTime.ofInstant( + Instant.ofEpochSecond(0), ZoneId.of("UTC"), + ), + isUpdate = true, + ), + ) + + coVerify(exactly = 0) { + folderService.getFolder(folderId = any()) + vaultDiskSource.saveFolder(userId = any(), folder = any()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `syncFolderUpsertFlow valid create success should make a request for a folder and then store it`() = + runTest { + val number = 1 + val userId = ACTIVE_USER_ID + val folderId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { + vaultDiskSource.getFolders(userId = userId) + } returns MutableStateFlow(emptyList()) + val folder = mockk() + coEvery { folderService.getFolder(folderId = folderId) } returns folder.asSuccess() + coEvery { vaultDiskSource.saveFolder(userId = userId, folder = folder) } just runs + + mutableSyncFolderUpsertFlow.tryEmit( + SyncFolderUpsertData( + folderId = folderId, + revisionDate = ZonedDateTime.now(FIXED_CLOCK), + isUpdate = false, + ), + ) + + coVerify(exactly = 1) { + folderService.getFolder(folderId = folderId) + vaultDiskSource.saveFolder(userId = userId, folder = folder) + } + } + + @Suppress("MaxLineLength") + @Test + fun `syncFolderUpsertFlow valid update success should make a request for a folder and then store it`() = + runTest { + val number = 1 + val userId = ACTIVE_USER_ID + val folderId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val folderView = createMockFolder( + number = number, + revisionDate = ZonedDateTime.now(FIXED_CLOCK).minus(5, ChronoUnit.MINUTES), + ) + coEvery { + vaultDiskSource.getFolders(userId = userId) + } returns MutableStateFlow(listOf(folderView)) + val folder = mockk() + coEvery { folderService.getFolder(folderId = folderId) } returns folder.asSuccess() + coEvery { vaultDiskSource.saveFolder(userId = userId, folder = folder) } just runs + + mutableSyncFolderUpsertFlow.tryEmit( + SyncFolderUpsertData( + folderId = folderId, + revisionDate = ZonedDateTime.now(FIXED_CLOCK), + isUpdate = true, + ), + ) + + coVerify(exactly = 1) { + folderService.getFolder(folderId = folderId) + vaultDiskSource.saveFolder(userId = userId, folder = folder) + } + } +} + +private val FIXED_CLOCK: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, +) + +private const val ACTIVE_USER_ID: String = "mockId-1" + +private val MOCK_PROFILE = AccountJson.Profile( + userId = ACTIVE_USER_ID, + email = "email", + isEmailVerified = true, + name = null, + stamp = "mockSecurityStamp-1", + organizationId = null, + avatarColorHex = null, + hasPremium = false, + forcePasswordResetReason = null, + kdfType = null, + kdfIterations = null, + kdfMemory = null, + kdfParallelism = null, + userDecryptionOptions = null, + isTwoFactorEnabled = false, + creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"), +) + +private val MOCK_ACCOUNT = AccountJson( + profile = MOCK_PROFILE, + tokens = AccountTokensJson( + accessToken = "accessToken", + refreshToken = "refreshToken", + ), + settings = AccountJson.Settings( + environmentUrlData = null, + ), +) + +private val MOCK_USER_STATE = UserStateJson( + activeUserId = ACTIVE_USER_ID, + accounts = mapOf( + ACTIVE_USER_ID to MOCK_ACCOUNT, + ), +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index afe0d83bcd..542fc07152 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -15,22 +15,18 @@ import com.bitwarden.data.manager.DispatcherManager import com.bitwarden.exporters.ExportFormat import com.bitwarden.fido.Fido2CredentialAutofillView import com.bitwarden.network.model.CipherTypeJson -import com.bitwarden.network.model.FolderJsonRequest import com.bitwarden.network.model.SyncResponseJson -import com.bitwarden.network.model.UpdateFolderResponseJson import com.bitwarden.network.model.createMockCipher import com.bitwarden.network.model.createMockCollection import com.bitwarden.network.model.createMockDomains import com.bitwarden.network.model.createMockFolder import com.bitwarden.network.model.createMockOrganizationKeys import com.bitwarden.network.model.createMockSend -import com.bitwarden.network.service.FolderService import com.bitwarden.sdk.Fido2CredentialStore import com.bitwarden.send.SendView import com.bitwarden.vault.CipherType import com.bitwarden.vault.CipherView import com.bitwarden.vault.DecryptCipherListResult -import com.bitwarden.vault.Folder import com.bitwarden.vault.FolderView import com.bitwarden.vault.TotpResponse import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson @@ -43,8 +39,6 @@ import com.x8bit.bitwarden.data.platform.error.MissingPropertyException import com.x8bit.bitwarden.data.platform.error.NoActiveUserException import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager import com.x8bit.bitwarden.data.platform.manager.PushManager -import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData -import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData 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.createMockAccount @@ -65,14 +59,11 @@ import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager import com.x8bit.bitwarden.data.vault.manager.model.ImportCxfPayloadResult import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem -import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult -import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult import com.x8bit.bitwarden.data.vault.repository.model.DomainsData import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult import com.x8bit.bitwarden.data.vault.repository.model.SendData -import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult @@ -81,7 +72,6 @@ import com.x8bit.bitwarden.data.vault.repository.util.toDomainsData import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollectionList -import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSendList import com.x8bit.bitwarden.ui.vault.feature.verificationcode.util.createVerificationCodeItem @@ -113,7 +103,6 @@ import java.security.GeneralSecurityException import java.security.MessageDigest import java.time.Clock import java.time.Instant -import java.time.ZoneId import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.temporal.ChronoUnit @@ -131,7 +120,6 @@ class VaultRepositoryTest { private val settingsDiskSource = mockk { every { getLastSyncTime(userId = any()) } returns clock.instant() } - private val folderService: FolderService = mockk() private val mutableGetCiphersFlow: MutableStateFlow> = MutableStateFlow(listOf(createMockCipher(1))) private val vaultDiskSource: VaultDiskSource = mockk { @@ -169,18 +157,13 @@ class VaultRepositoryTest { every { databaseSchemeChangeFlow } returns mutableDatabaseSchemeChangeFlow } private val mutableFullSyncFlow = bufferedMutableSharedFlow() - private val mutableSyncFolderDeleteFlow = bufferedMutableSharedFlow() - private val mutableSyncFolderUpsertFlow = bufferedMutableSharedFlow() private val pushManager: PushManager = mockk { every { fullSyncFlow } returns mutableFullSyncFlow - every { syncFolderDeleteFlow } returns mutableSyncFolderDeleteFlow - every { syncFolderUpsertFlow } returns mutableSyncFolderUpsertFlow } private val vaultSyncManager: VaultSyncManager = mockk() private val credentialExchangeImportManager: CredentialExchangeImportManager = mockk() private val vaultRepository = VaultRepositoryImpl( - folderService = folderService, vaultDiskSource = vaultDiskSource, vaultSdkSource = vaultSdkSource, authDiskSource = fakeAuthDiskSource, @@ -190,6 +173,7 @@ class VaultRepositoryTest { totpCodeManager = totpCodeManager, pushManager = pushManager, cipherManager = mockk(), + folderManager = mockk(), sendManager = mockk(), clock = clock, databaseSchemeManager = databaseSchemeManager, @@ -2063,362 +2047,6 @@ class VaultRepositoryTest { ) } - @Test - fun `deleteFolder with no active user should return DeleteFolderResult failure`() = - runTest { - fakeAuthDiskSource.userState = null - - val result = vaultRepository.deleteFolder("Test") - - assertEquals( - DeleteFolderResult.Error(error = NoActiveUserException()), - result, - ) - } - - @Suppress("MaxLineLength") - @Test - fun `DeleteFolder with folderService Delete failure should return DeleteFolderResult Failure`() = - runTest { - fakeAuthDiskSource.userState = MOCK_USER_STATE - val error = Throwable("fail") - val folderId = "mockId-1" - coEvery { folderService.deleteFolder(folderId) } returns error.asFailure() - - val result = vaultRepository.deleteFolder(folderId) - assertEquals(DeleteFolderResult.Error(error = error), result) - } - - @Suppress("MaxLineLength") - @Test - fun `DeleteFolder with folderService Delete success should return DeleteFolderResult Success and update ciphers`() = - runTest { - fakeAuthDiskSource.userState = MOCK_USER_STATE - val userId = MOCK_USER_STATE.activeUserId - val folderId = "mockFolderId-1" - val mockCipher = createMockCipher(number = 1) - val ciphers = listOf(mockCipher, createMockCipher(number = 2)) - coEvery { folderService.deleteFolder(folderId) } returns Unit.asSuccess() - coEvery { vaultDiskSource.deleteFolder(userId = userId, folderId = folderId) } just runs - coEvery { vaultDiskSource.getCiphers(userId = userId) } returns ciphers - coEvery { - vaultDiskSource.saveCipher( - userId = userId, - cipher = mockCipher.copy(folderId = null), - ) - } just runs - - val result = vaultRepository.deleteFolder(folderId = folderId) - - coVerify(exactly = 1) { - vaultDiskSource.saveCipher( - userId = userId, - cipher = mockCipher.copy(folderId = null), - ) - } - - assertEquals(DeleteFolderResult.Success, result) - } - - @Test - fun `createFolder with no active user should return CreateFolderResult failure`() = - runTest { - fakeAuthDiskSource.userState = null - - val result = vaultRepository.createFolder(mockk()) - - assertEquals( - CreateFolderResult.Error(NoActiveUserException()), - result, - ) - } - - @Suppress("MaxLineLength") - @Test - fun `createFolder with folderService Delete failure should return DeleteFolderResult Failure`() = - runTest { - fakeAuthDiskSource.userState = MOCK_USER_STATE - val folderId = "mockId-1" - val error = Throwable("fail") - coEvery { folderService.deleteFolder(folderId) } returns error.asFailure() - - val result = vaultRepository.deleteFolder(folderId) - assertEquals(DeleteFolderResult.Error(error = error), result) - } - - @Test - fun `createFolder with encryptFolder failure should return CreateFolderResult failure`() = - runTest { - fakeAuthDiskSource.userState = MOCK_USER_STATE - val folderView = FolderView( - id = null, - name = "TestName", - revisionDate = DateTime.now(), - ) - val error = IllegalStateException() - - coEvery { - vaultSdkSource.encryptFolder( - userId = MOCK_USER_STATE.activeUserId, - folder = folderView, - ) - } returns error.asFailure() - - val result = vaultRepository.createFolder(folderView) - assertEquals(CreateFolderResult.Error(error = error), result) - } - - @Test - fun `createFolder with folderService failure should return CreateFolderResult failure`() = - runTest { - val date = DateTime.now() - val testFolderName = "TestName" - - fakeAuthDiskSource.userState = MOCK_USER_STATE - val folderView = FolderView( - id = null, - name = testFolderName, - revisionDate = date, - ) - val error = IllegalStateException() - - coEvery { - vaultSdkSource.encryptFolder( - userId = MOCK_USER_STATE.activeUserId, - folder = folderView, - ) - } returns Folder(id = null, name = testFolderName, revisionDate = date).asSuccess() - - coEvery { - folderService.createFolder( - body = FolderJsonRequest(testFolderName), - ) - } returns error.asFailure() - - val result = vaultRepository.createFolder(folderView) - assertEquals(CreateFolderResult.Error(error = error), result) - } - - @Test - fun `createFolder with folderService createFolder should return CreateFolderResult success`() = - runTest { - fakeAuthDiskSource.userState = MOCK_USER_STATE - val date = DateTime.now() - val testFolderName = "TestName" - - val folderView = FolderView( - id = null, - name = testFolderName, - revisionDate = date, - ) - - val networkFolder = SyncResponseJson.Folder( - id = "1", - name = testFolderName, - revisionDate = ZonedDateTime.now(), - ) - - coEvery { - vaultSdkSource.encryptFolder( - userId = MOCK_USER_STATE.activeUserId, - folder = folderView, - ) - } returns Folder(id = null, name = testFolderName, revisionDate = date).asSuccess() - - coEvery { - folderService.createFolder( - body = FolderJsonRequest(testFolderName), - ) - } returns networkFolder.asSuccess() - - coEvery { - vaultDiskSource.saveFolder( - MOCK_USER_STATE.activeUserId, - networkFolder, - ) - } just runs - - coEvery { - vaultSdkSource.decryptFolder( - MOCK_USER_STATE.activeUserId, - networkFolder.toEncryptedSdkFolder(), - ) - } returns folderView.asSuccess() - - val result = vaultRepository.createFolder(folderView) - assertEquals(CreateFolderResult.Success(folderView), result) - } - - @Test - fun `updateFolder with no active user should return UpdateFolderResult failure`() = - runTest { - fakeAuthDiskSource.userState = null - - val result = vaultRepository.updateFolder("Test", mockk()) - - assertEquals( - UpdateFolderResult.Error(errorMessage = null, error = NoActiveUserException()), - result, - ) - } - - @Test - fun `updateFolder with encryptFolder failure should return UpdateFolderResult failure`() = - runTest { - fakeAuthDiskSource.userState = MOCK_USER_STATE - val folderId = "testId" - val folderView = FolderView( - id = folderId, - name = "TestName", - revisionDate = DateTime.now(), - ) - val error = IllegalStateException() - - coEvery { - vaultSdkSource.encryptFolder( - userId = MOCK_USER_STATE.activeUserId, - folder = folderView, - ) - } returns error.asFailure() - - val result = vaultRepository.updateFolder(folderId, folderView) - - assertEquals(UpdateFolderResult.Error(errorMessage = null, error = error), result) - } - - @Test - fun `updateFolder with folderService failure should return UpdateFolderResult failure`() = - runTest { - val date = DateTime.now() - val testFolderName = "TestName" - val folderId = "testId" - - fakeAuthDiskSource.userState = MOCK_USER_STATE - val folderView = FolderView( - id = folderId, - name = testFolderName, - revisionDate = date, - ) - val error = IllegalStateException() - - coEvery { - vaultSdkSource.encryptFolder( - userId = MOCK_USER_STATE.activeUserId, - folder = folderView, - ) - } returns Folder(id = folderId, name = testFolderName, revisionDate = date).asSuccess() - - coEvery { - folderService.updateFolder( - folderId = folderId, - body = FolderJsonRequest(testFolderName), - ) - } returns error.asFailure() - - val result = vaultRepository.updateFolder(folderId, folderView) - assertEquals(UpdateFolderResult.Error(errorMessage = null, error = error), result) - } - - @Suppress("MaxLineLength") - @Test - fun `updateFolder with folderService updateFolder Invalid response should return UpdateFolderResult Error with a non-null message`() = - runTest { - val date = DateTime.now() - val testFolderName = "TestName" - val folderId = "testId" - - fakeAuthDiskSource.userState = MOCK_USER_STATE - val folderView = FolderView( - id = folderId, - name = testFolderName, - revisionDate = date, - ) - - coEvery { - vaultSdkSource.encryptFolder( - userId = MOCK_USER_STATE.activeUserId, - folder = folderView, - ) - } returns Folder(id = folderId, name = testFolderName, revisionDate = date).asSuccess() - - coEvery { - folderService.updateFolder( - folderId = folderId, - body = FolderJsonRequest(testFolderName), - ) - } returns UpdateFolderResponseJson - .Invalid( - message = "You do not have permission to edit this.", - validationErrors = null, - ) - .asSuccess() - - val result = vaultRepository.updateFolder(folderId, folderView) - assertEquals( - UpdateFolderResult.Error( - errorMessage = "You do not have permission to edit this.", - error = null, - ), - result, - ) - } - - @Suppress("MaxLineLength") - @Test - fun `updateFolder with folderService updateFolder success should return UpdateFolderResult success`() = - runTest { - val date = DateTime.now() - val testFolderName = "TestName" - val folderId = "testId" - - fakeAuthDiskSource.userState = MOCK_USER_STATE - - val folderView = FolderView( - id = folderId, - name = testFolderName, - revisionDate = date, - ) - - val networkFolder = SyncResponseJson.Folder( - id = "1", - name = testFolderName, - revisionDate = ZonedDateTime.now(), - ) - - coEvery { - vaultSdkSource.encryptFolder( - userId = MOCK_USER_STATE.activeUserId, - folder = folderView, - ) - } returns Folder(id = folderId, name = testFolderName, revisionDate = date).asSuccess() - - coEvery { - folderService.updateFolder( - folderId = folderId, - body = FolderJsonRequest(testFolderName), - ) - } returns UpdateFolderResponseJson - .Success(folder = networkFolder) - .asSuccess() - - coEvery { - vaultDiskSource.saveFolder( - MOCK_USER_STATE.activeUserId, - networkFolder, - ) - } just runs - - coEvery { - vaultSdkSource.decryptFolder( - MOCK_USER_STATE.activeUserId, - networkFolder.toEncryptedSdkFolder(), - ) - } returns folderView.asSuccess() - - val result = vaultRepository.updateFolder(folderId, folderView) - assertEquals(UpdateFolderResult.Success(folderView), result) - } - @Test fun `getAuthCodeFlow with no active user should emit an error`() = runTest { fakeAuthDiskSource.userState = null @@ -2533,210 +2161,6 @@ class VaultRepositoryTest { coVerify { vaultSyncManager.sync(any(), any()) } } - @Test - fun `syncFolderDeleteFlow should delete folder from disk and update ciphers`() { - val userId = "mockId-1" - val folderId = "mockId-1" - val cipher = createMockCipher(number = 1, folderId = folderId) - val updatedCipher = createMockCipher(number = 1, folderId = null) - - coEvery { vaultDiskSource.deleteFolder(userId = userId, folderId = folderId) } just runs - coEvery { vaultDiskSource.getCiphers(userId = userId) } returns listOf(cipher) - coEvery { vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher) } just runs - - mutableSyncFolderDeleteFlow.tryEmit( - SyncFolderDeleteData(userId = userId, folderId = folderId), - ) - - coVerify(exactly = 1) { - vaultDiskSource.deleteFolder(userId = userId, folderId = folderId) - vaultDiskSource.getCiphers(userId = userId) - vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher) - } - } - - @Test - fun `syncFolderUpsertFlow create with local folder should do nothing`() = runTest { - val number = 1 - val folderId = "mockId-$number" - - fakeAuthDiskSource.userState = MOCK_USER_STATE - setVaultToUnlocked(userId = MOCK_USER_STATE.activeUserId) - val folderView = createMockFolderView(number = number) - coEvery { - vaultSdkSource.decryptFolderList( - userId = MOCK_USER_STATE.activeUserId, - folderList = listOf(createMockSdkFolder(number = number)), - ) - } returns listOf(folderView).asSuccess() - - val foldersFlow = bufferedMutableSharedFlow>() - setupVaultDiskSourceFlows(foldersFlow = foldersFlow) - - vaultRepository.foldersStateFlow.test { - // Populate and consume items related to the folders flow - awaitItem() - foldersFlow.tryEmit(listOf(createMockFolder(number = number))) - awaitItem() - - mutableSyncFolderUpsertFlow.tryEmit( - SyncFolderUpsertData( - folderId = folderId, - revisionDate = ZonedDateTime.now(), - isUpdate = false, - ), - ) - } - - coVerify(exactly = 0) { - folderService.getFolder(any()) - vaultDiskSource.saveFolder(any(), any()) - } - } - - @Test - fun `syncFolderUpsertFlow update with no local folder should do nothing`() = runTest { - val number = 1 - val folderId = "mockId-$number" - - fakeAuthDiskSource.userState = MOCK_USER_STATE - setVaultToUnlocked(userId = MOCK_USER_STATE.activeUserId) - coEvery { - vaultSdkSource.decryptFolderList( - userId = MOCK_USER_STATE.activeUserId, - folderList = listOf(), - ) - } returns listOf().asSuccess() - - val foldersFlow = bufferedMutableSharedFlow>() - setupVaultDiskSourceFlows(foldersFlow = foldersFlow) - - vaultRepository.foldersStateFlow.test { - // Populate and consume items related to the folders flow - awaitItem() - foldersFlow.tryEmit(listOf()) - awaitItem() - - mutableSyncFolderUpsertFlow.tryEmit( - SyncFolderUpsertData( - folderId = folderId, - revisionDate = ZonedDateTime.now(), - isUpdate = true, - ), - ) - } - - coVerify(exactly = 0) { - folderService.getFolder(any()) - vaultDiskSource.saveFolder(any(), any()) - } - } - - @Test - fun `syncFolderUpsertFlow update with more recent local folder should do nothing`() = runTest { - val number = 1 - val folderId = "mockId-$number" - - fakeAuthDiskSource.userState = MOCK_USER_STATE - setVaultToUnlocked(userId = MOCK_USER_STATE.activeUserId) - val folderView = createMockFolderView(number = number) - coEvery { - vaultSdkSource.decryptFolderList( - userId = MOCK_USER_STATE.activeUserId, - folderList = listOf(createMockSdkFolder(number = number)), - ) - } returns listOf(folderView).asSuccess() - - val foldersFlow = bufferedMutableSharedFlow>() - setupVaultDiskSourceFlows(foldersFlow = foldersFlow) - - vaultRepository.foldersStateFlow.test { - // Populate and consume items related to the folders flow - awaitItem() - foldersFlow.tryEmit(listOf(createMockFolder(number = number))) - awaitItem() - - mutableSyncFolderUpsertFlow.tryEmit( - SyncFolderUpsertData( - folderId = folderId, - revisionDate = ZonedDateTime.ofInstant( - Instant.ofEpochSecond(0), ZoneId.of("UTC"), - ), - isUpdate = true, - ), - ) - } - - coVerify(exactly = 0) { - folderService.getFolder(any()) - vaultDiskSource.saveFolder(any(), any()) - } - } - - @Suppress("MaxLineLength") - @Test - fun `syncFolderUpsertFlow valid create success should make a request for a folder and then store it`() = - runTest { - val number = 1 - val userId = MOCK_USER_STATE.activeUserId - val folderId = "mockId-$number" - - fakeAuthDiskSource.userState = MOCK_USER_STATE - coEvery { - vaultDiskSource.getFolders(userId = userId) - } returns MutableStateFlow(emptyList()) - val folder = mockk() - coEvery { folderService.getFolder(folderId = folderId) } returns folder.asSuccess() - coEvery { vaultDiskSource.saveFolder(userId = userId, folder = folder) } just runs - - mutableSyncFolderUpsertFlow.tryEmit( - SyncFolderUpsertData( - folderId = folderId, - revisionDate = ZonedDateTime.now(clock), - isUpdate = false, - ), - ) - - coVerify(exactly = 1) { - folderService.getFolder(folderId = folderId) - vaultDiskSource.saveFolder(userId = userId, folder = folder) - } - } - - @Suppress("MaxLineLength") - @Test - fun `syncFolderUpsertFlow valid update success should make a request for a folder and then store it`() = - runTest { - val number = 1 - val userId = MOCK_USER_STATE.activeUserId - val folderId = "mockId-$number" - - fakeAuthDiskSource.userState = MOCK_USER_STATE - val folderView = createMockFolder( - number = number, - revisionDate = ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES), - ) - coEvery { - vaultDiskSource.getFolders(userId = userId) - } returns MutableStateFlow(listOf(folderView)) - val folder = mockk() - coEvery { folderService.getFolder(folderId = folderId) } returns folder.asSuccess() - coEvery { vaultDiskSource.saveFolder(userId = userId, folder = folder) } just runs - - mutableSyncFolderUpsertFlow.tryEmit( - SyncFolderUpsertData( - folderId = folderId, - revisionDate = ZonedDateTime.now(clock), - isUpdate = true, - ), - ) - - coVerify(exactly = 1) { - folderService.getFolder(folderId) - vaultDiskSource.saveFolder(userId = userId, folder = folder) - } - } - @Suppress("MaxLineLength") @Test fun `exportVaultDataToString should return a success result when data is successfully converted for export`() =