From f954b0b9414fe81571b9998258f5bdeeb278b6fe Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Tue, 16 Sep 2025 12:44:52 -0400 Subject: [PATCH] Refactor Vault Sync Logic into VaultSyncManager (#5871) --- .../error/SecurityStampMismatchException.kt | 6 + .../data/vault/manager/VaultSyncManager.kt | 29 + .../vault/manager/VaultSyncManagerImpl.kt | 176 ++++++ .../vault/manager/di/VaultManagerModule.kt | 24 + .../model/SyncVaultDataResult.kt | 6 +- .../data/vault/repository/VaultRepository.kt | 2 +- .../vault/repository/VaultRepositoryImpl.kt | 162 +----- .../repository/di/VaultRepositoryModule.kt | 9 +- .../importlogins/ImportLoginsViewModel.kt | 2 +- .../vault/manager/VaultSyncManagerTest.kt | 291 ++++++++++ .../vault/repository/VaultRepositoryTest.kt | 533 +++++------------- .../importlogins/ImportLoginsViewModelTest.kt | 2 +- 12 files changed, 679 insertions(+), 563 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/platform/error/SecurityStampMismatchException.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManager.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt rename app/src/main/kotlin/com/x8bit/bitwarden/data/vault/{repository => manager}/model/SyncVaultDataResult.kt (76%) create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/error/SecurityStampMismatchException.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/error/SecurityStampMismatchException.kt new file mode 100644 index 0000000000..0daf3c2683 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/error/SecurityStampMismatchException.kt @@ -0,0 +1,6 @@ +package com.x8bit.bitwarden.data.platform.error + +/** + * An exception indicating that the security stamps for the current user do not match. + */ +class SecurityStampMismatchException : IllegalStateException("Security stamps do not match!") diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManager.kt new file mode 100644 index 0000000000..7b05e90167 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManager.kt @@ -0,0 +1,29 @@ +package com.x8bit.bitwarden.data.vault.manager + +import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult + +/** + * Manages the synchronization of the user's vault data with the remote server. + * This interface provides a way to trigger a sync process, which updates the local + * database with the latest changes from the server. + */ +interface VaultSyncManager { + /** + * Initiates a synchronization process for the user's vault data. + * + * This function fetches the latest data from the remote server and updates the local + * vault cache. It can be a standard sync or a "forced" sync, which typically + * bypasses local cache checks and fetches everything anew. + * + * @param userId The unique identifier of the user whose vault is to be synchronized. + * @param forced If true, performs a full, forced synchronization, ignoring any recent sync + * timestamps. If false, performs a standard incremental sync. + * + * @return A [SyncVaultDataResult] indicating the outcome of the synchronization, such as + * success or failure with details. + */ + suspend fun sync( + userId: String, + forced: Boolean, + ): SyncVaultDataResult +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt new file mode 100644 index 0000000000..61f610d1a1 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt @@ -0,0 +1,176 @@ +package com.x8bit.bitwarden.data.vault.manager + +import com.bitwarden.core.InitOrgCryptoRequest +import com.bitwarden.network.model.SyncResponseJson +import com.bitwarden.network.service.SyncService +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager +import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason +import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson +import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import com.x8bit.bitwarden.data.platform.error.SecurityStampMismatchException +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.SyncVaultDataResult +import kotlinx.coroutines.flow.firstOrNull +import java.time.Clock + +/** + * Default implementation of [VaultSyncManager]. + */ +@Suppress("LongParameterList") +class VaultSyncManagerImpl( + private val syncService: SyncService, + private val settingsDiskSource: SettingsDiskSource, + private val authDiskSource: AuthDiskSource, + private val vaultDiskSource: VaultDiskSource, + private val vaultSdkSource: VaultSdkSource, + private val userLogoutManager: UserLogoutManager, + private val clock: Clock, +) : VaultSyncManager { + + @Suppress("LongMethod") + override suspend fun sync( + userId: String, + forced: Boolean, + ): SyncVaultDataResult { + if (!forced) { + // Skip this check if we are forcing the request. + val lastSyncInstant = settingsDiskSource + .getLastSyncTime(userId = userId) + ?.toEpochMilli() + lastSyncInstant?.let { lastSyncTimeMs -> + // If the lasSyncState is null we just sync, no checks required. + syncService + .getAccountRevisionDateMillis() + .fold( + onSuccess = { serverRevisionDate -> + if (serverRevisionDate < lastSyncTimeMs) { + // We can skip the actual sync call if there is no new data or + // database scheme changes since the last sync. + settingsDiskSource.storeLastSyncTime( + userId = userId, + lastSyncTime = clock.instant(), + ) + vaultDiskSource.resyncVaultData(userId = userId) + val itemsAvailable = vaultDiskSource + .getCiphersFlow(userId) + .firstOrNull() + ?.isNotEmpty() == true + return SyncVaultDataResult.Success(itemsAvailable = itemsAvailable) + } + }, + onFailure = { + return SyncVaultDataResult.Error(throwable = it) + }, + ) + } + } + + return syncService + .sync() + .fold( + onSuccess = { syncResponse -> + val localSecurityStamp = authDiskSource.userState?.activeAccount?.profile?.stamp + val serverSecurityStamp = syncResponse.profile.securityStamp + + // Log the user out if the stamps do not match + localSecurityStamp?.let { + if (serverSecurityStamp != localSecurityStamp) { + userLogoutManager.softLogout( + // Ensure UserLogoutManager is available + userId = userId, + reason = LogoutReason.SecurityStamp, + ) + return SyncVaultDataResult.Error( + throwable = SecurityStampMismatchException(), + ) + } + } + + // Update user information with additional information from sync response + authDiskSource.userState = authDiskSource.userState?.toUpdatedUserStateJson( + syncResponse = syncResponse, + ) + + unlockVaultForOrganizationsIfNecessary(syncResponse = syncResponse) + storeProfileData(syncResponse = syncResponse) + + // Treat absent network policies as known empty data to + // distinguish between unknown null data. + authDiskSource.storePolicies( + userId = userId, + policies = syncResponse.policies.orEmpty(), + ) + settingsDiskSource.storeLastSyncTime( + userId = userId, + lastSyncTime = clock.instant(), + ) + vaultDiskSource.replaceVaultData(userId = userId, vault = syncResponse) + val itemsAvailable = syncResponse.ciphers?.isNotEmpty() == true + SyncVaultDataResult.Success(itemsAvailable = itemsAvailable) + }, + onFailure = { throwable -> + SyncVaultDataResult.Error(throwable = throwable) + }, + ) + } + + private suspend fun unlockVaultForOrganizationsIfNecessary( + syncResponse: SyncResponseJson, + ) { + val profile = syncResponse.profile + val organizationKeys = profile.organizations + .orEmpty() + .filter { it.key != null } + .associate { it.id to requireNotNull(it.key) } + .takeUnless { it.isEmpty() } + ?: return + + // There shouldn't be issues when unlocking directly from the syncResponse so we can ignore + // the return type here. + vaultSdkSource + .initializeOrganizationCrypto( + userId = syncResponse.profile.id, + request = InitOrgCryptoRequest( + organizationKeys = organizationKeys, + ), + ) + } + + private fun storeProfileData( + syncResponse: SyncResponseJson, + ) { + val profile = syncResponse.profile + val userId = profile.id + authDiskSource.apply { + storeUserKey( + userId = userId, + userKey = profile.key, + ) + storePrivateKey( + userId = userId, + privateKey = profile.privateKey, + ) + storeAccountKeys( + userId = userId, + accountKeys = profile.accountKeys, + ) + storeOrganizationKeys( + userId = userId, + organizationKeys = profile.organizations + .orEmpty() + .filter { it.key != null } + .associate { it.id to requireNotNull(it.key) }, + ) + storeShouldUseKeyConnector( + userId = userId, + shouldUseKeyConnector = profile.shouldUseKeyConnector, + ) + storeOrganizations( + userId = userId, + organizations = profile.organizations, + ) + } + } +} 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 d737952697..0198e3cdbb 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,10 +5,12 @@ 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.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager +import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.AppStateManager import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -22,6 +24,8 @@ import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager import com.x8bit.bitwarden.data.vault.manager.TotpCodeManagerImpl import com.x8bit.bitwarden.data.vault.manager.VaultLockManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl +import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager +import com.x8bit.bitwarden.data.vault.manager.VaultSyncManagerImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -110,4 +114,24 @@ object VaultManagerModule { dispatcherManager = dispatcherManager, clock = clock, ) + + @Provides + @Singleton + fun provideVaultSyncManager( + syncService: SyncService, + settingsDiskSource: SettingsDiskSource, + authDiskSource: AuthDiskSource, + vaultDiskSource: VaultDiskSource, + vaultSdkSource: VaultSdkSource, + userLogoutManager: UserLogoutManager, + clock: Clock, + ): VaultSyncManager = VaultSyncManagerImpl( + syncService = syncService, + settingsDiskSource = settingsDiskSource, + authDiskSource = authDiskSource, + vaultDiskSource = vaultDiskSource, + vaultSdkSource = vaultSdkSource, + userLogoutManager = userLogoutManager, + clock = clock, + ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/SyncVaultDataResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/model/SyncVaultDataResult.kt similarity index 76% rename from app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/SyncVaultDataResult.kt rename to app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/model/SyncVaultDataResult.kt index d38d9c9af3..3d223dc983 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/SyncVaultDataResult.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/model/SyncVaultDataResult.kt @@ -1,4 +1,4 @@ -package com.x8bit.bitwarden.data.vault.repository.model +package com.x8bit.bitwarden.data.vault.manager.model /** * Represents the result of a sync operation. @@ -14,7 +14,7 @@ sealed class SyncVaultDataResult { /** * Indicates a failed sync operation. * - * @property throwable The exception that caused the failure, if any. + * @property throwable The exception that caused the failure. */ - data class Error(val throwable: Throwable?) : SyncVaultDataResult() + data class Error(val throwable: Throwable) : SyncVaultDataResult() } 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 278f049919..10c5522949 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 @@ -16,6 +16,7 @@ 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.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.CreateSendResult @@ -27,7 +28,6 @@ import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.ImportCxfPayloadResult import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.SendData -import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult 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.UpdateSendResult 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 7a7b478ff8..57c1e781ab 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 @@ -3,7 +3,6 @@ package com.x8bit.bitwarden.data.vault.repository import android.net.Uri import com.bitwarden.collections.CollectionView import com.bitwarden.core.DateTime -import com.bitwarden.core.InitOrgCryptoRequest import com.bitwarden.core.InitUserCryptoMethod import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow @@ -19,13 +18,11 @@ import com.bitwarden.exporters.ExportFormat import com.bitwarden.fido.Fido2CredentialAutofillView import com.bitwarden.network.model.CreateFileSendResponse import com.bitwarden.network.model.CreateSendJsonResponse -import com.bitwarden.network.model.SyncResponseJson import com.bitwarden.network.model.UpdateFolderResponseJson import com.bitwarden.network.model.UpdateSendResponseJson import com.bitwarden.network.service.CiphersService import com.bitwarden.network.service.FolderService import com.bitwarden.network.service.SendsService -import com.bitwarden.network.service.SyncService import com.bitwarden.network.util.isNoConnectionError import com.bitwarden.sdk.Fido2CredentialStore import com.bitwarden.send.Send @@ -38,10 +35,7 @@ import com.bitwarden.vault.CipherView import com.bitwarden.vault.DecryptCipherListResult import com.bitwarden.vault.FolderView import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource -import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager -import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams -import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow import com.x8bit.bitwarden.data.autofill.util.login import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource @@ -64,7 +58,9 @@ import com.x8bit.bitwarden.data.vault.manager.CipherManager import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManager +import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult +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.CreateSendResult @@ -76,7 +72,6 @@ import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.ImportCxfPayloadResult import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.SendData -import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult 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.UpdateSendResult @@ -140,7 +135,6 @@ private const val STOP_TIMEOUT_DELAY_MS: Long = 1000L */ @Suppress("TooManyFunctions", "LongParameterList", "LargeClass") class VaultRepositoryImpl( - private val syncService: SyncService, private val ciphersService: CiphersService, private val sendsService: SendsService, private val folderService: FolderService, @@ -152,12 +146,12 @@ class VaultRepositoryImpl( private val fileManager: FileManager, private val vaultLockManager: VaultLockManager, private val totpCodeManager: TotpCodeManager, - private val userLogoutManager: UserLogoutManager, databaseSchemeManager: DatabaseSchemeManager, pushManager: PushManager, private val clock: Clock, dispatcherManager: DispatcherManager, private val reviewPromptManager: ReviewPromptManager, + private val vaultSyncManager: VaultSyncManager, ) : VaultRepository, CipherManager by cipherManager, VaultLockManager by vaultLockManager { @@ -378,12 +372,13 @@ class VaultRepositoryImpl( if (lastSyncInstant == null || currentInstant.isAfter(lastSyncInstant.plus(30, ChronoUnit.MINUTES)) ) { - sync() + sync(forced = false) } } override suspend fun syncForResult(): SyncVaultDataResult { - val userId = activeUserId ?: return SyncVaultDataResult.Error(throwable = null) + val userId = activeUserId + ?: return SyncVaultDataResult.Error(throwable = NoActiveUserException()) syncJob = ioScope .async { syncInternal(userId = userId, forced = false) } .also { @@ -1036,42 +1031,6 @@ class VaultRepositoryImpl( } } - private fun storeProfileData( - syncResponse: SyncResponseJson, - ) { - val profile = syncResponse.profile - val userId = profile.id - authDiskSource.apply { - storeUserKey( - userId = userId, - userKey = profile.key, - ) - storePrivateKey( - userId = userId, - privateKey = profile.privateKey, - ) - storeAccountKeys( - userId = userId, - accountKeys = profile.accountKeys, - ) - storeOrganizationKeys( - userId = userId, - organizationKeys = profile.organizations - .orEmpty() - .filter { it.key != null } - .associate { it.id to requireNotNull(it.key) }, - ) - storeShouldUseKeyConnector( - userId = userId, - shouldUseKeyConnector = profile.shouldUseKeyConnector, - ) - storeOrganizations( - userId = userId, - organizations = profile.organizations, - ) - } - } - private suspend fun unlockVaultForUser( userId: String, initUserCryptoMethod: InitUserCryptoMethod, @@ -1102,28 +1061,6 @@ class VaultRepositoryImpl( ) } - private suspend fun unlockVaultForOrganizationsIfNecessary( - syncResponse: SyncResponseJson, - ) { - val profile = syncResponse.profile - val organizationKeys = profile.organizations - .orEmpty() - .filter { it.key != null } - .associate { it.id to requireNotNull(it.key) } - .takeUnless { it.isEmpty() } - ?: return - - // There shouldn't be issues when unlocking directly from the syncResponse so we can ignore - // the return type here. - vaultSdkSource - .initializeOrganizationCrypto( - userId = syncResponse.profile.id, - request = InitOrgCryptoRequest( - organizationKeys = organizationKeys, - ), - ) - } - private fun observeVaultDiskCiphersToCipherListView( userId: String, ): Flow> = @@ -1500,90 +1437,17 @@ class VaultRepositoryImpl( } //endregion Push Notification helpers - @Suppress("LongMethod") private suspend fun syncInternal( userId: String, forced: Boolean, - ): SyncVaultDataResult { - if (!forced) { - // Skip this check if we are forcing the request. - val lastSyncInstant = settingsDiskSource - .getLastSyncTime(userId = userId) - ?.toEpochMilli() - lastSyncInstant?.let { lastSyncTimeMs -> - // If the lasSyncState is null we just sync, no checks required. - syncService - .getAccountRevisionDateMillis() - .fold( - onSuccess = { serverRevisionDate -> - if (serverRevisionDate < lastSyncTimeMs) { - // We can skip the actual sync call if there is no new data or - // database scheme changes since the last sync. - settingsDiskSource.storeLastSyncTime( - userId = userId, - lastSyncTime = clock.instant(), - ) - vaultDiskSource.resyncVaultData(userId = userId) - val itemsAvailable = vaultDiskSource - .getCiphersFlow(userId) - .firstOrNull() - ?.isNotEmpty() == true - return SyncVaultDataResult.Success(itemsAvailable = itemsAvailable) - } - }, - onFailure = { - updateVaultStateFlowsToError(throwable = it) - return SyncVaultDataResult.Error(throwable = it) - }, - ) + ): SyncVaultDataResult = + vaultSyncManager + .sync(userId = userId, forced = forced) + .also { result -> + if (result is SyncVaultDataResult.Error) { + updateVaultStateFlowsToError(throwable = result.throwable) + } } - } - - return syncService - .sync() - .fold( - onSuccess = { syncResponse -> - val localSecurityStamp = authDiskSource.userState?.activeAccount?.profile?.stamp - val serverSecurityStamp = syncResponse.profile.securityStamp - - // Log the user out if the stamps do not match - localSecurityStamp?.let { - if (serverSecurityStamp != localSecurityStamp) { - userLogoutManager.softLogout( - userId = userId, - reason = LogoutReason.SecurityStamp, - ) - return SyncVaultDataResult.Error(throwable = null) - } - } - - // Update user information with additional information from sync response - authDiskSource.userState = authDiskSource.userState?.toUpdatedUserStateJson( - syncResponse = syncResponse, - ) - - unlockVaultForOrganizationsIfNecessary(syncResponse = syncResponse) - storeProfileData(syncResponse = syncResponse) - // Treat absent network policies as known empty data to - // distinguish between unknown null data. - authDiskSource.storePolicies( - userId = userId, - policies = syncResponse.policies.orEmpty(), - ) - settingsDiskSource.storeLastSyncTime( - userId = userId, - lastSyncTime = clock.instant(), - ) - vaultDiskSource.replaceVaultData(userId = userId, vault = syncResponse) - val itemsAvailable = syncResponse.ciphers?.isNotEmpty() == true - SyncVaultDataResult.Success(itemsAvailable = itemsAvailable) - }, - onFailure = { throwable -> - updateVaultStateFlowsToError(throwable = throwable) - SyncVaultDataResult.Error(throwable = throwable) - }, - ) - } } private fun Throwable.toNetworkOrErrorState(data: T?): DataState = 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 35fba40162..ee4a0e90bd 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 @@ -4,9 +4,7 @@ import com.bitwarden.data.manager.DispatcherManager import com.bitwarden.network.service.CiphersService 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.manager.UserLogoutManager import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager import com.x8bit.bitwarden.data.platform.manager.PushManager @@ -17,6 +15,7 @@ import com.x8bit.bitwarden.data.vault.manager.CipherManager import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManager +import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepositoryImpl import dagger.Module @@ -36,7 +35,6 @@ object VaultRepositoryModule { @Provides @Singleton fun providesVaultRepository( - syncService: SyncService, sendsService: SendsService, ciphersService: CiphersService, folderService: FolderService, @@ -50,12 +48,11 @@ object VaultRepositoryModule { dispatcherManager: DispatcherManager, totpCodeManager: TotpCodeManager, pushManager: PushManager, - userLogoutManager: UserLogoutManager, databaseSchemeManager: DatabaseSchemeManager, clock: Clock, reviewPromptManager: ReviewPromptManager, + vaultSyncManager: VaultSyncManager, ): VaultRepository = VaultRepositoryImpl( - syncService = syncService, sendsService = sendsService, ciphersService = ciphersService, folderService = folderService, @@ -69,9 +66,9 @@ object VaultRepositoryModule { dispatcherManager = dispatcherManager, totpCodeManager = totpCodeManager, pushManager = pushManager, - userLogoutManager = userLogoutManager, databaseSchemeManager = databaseSchemeManager, clock = clock, reviewPromptManager = reviewPromptManager, + vaultSyncManager = vaultSyncManager, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModel.kt index 4a85984739..09b83ad53c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModel.kt @@ -9,8 +9,8 @@ import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.util.toUriOrNull +import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult import com.x8bit.bitwarden.data.vault.repository.VaultRepository -import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt new file mode 100644 index 0000000000..16b62bc719 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt @@ -0,0 +1,291 @@ +package com.x8bit.bitwarden.data.vault.manager + +import com.bitwarden.core.InitOrgCryptoRequest +import com.bitwarden.core.data.util.asFailure +import com.bitwarden.core.data.util.asSuccess +import com.bitwarden.network.model.SyncResponseJson +import com.bitwarden.network.model.createMockCipher +import com.bitwarden.network.model.createMockOrganization +import com.bitwarden.network.model.createMockOrganizationKeys +import com.bitwarden.network.model.createMockPolicy +import com.bitwarden.network.model.createMockProfile +import com.bitwarden.network.model.createMockSyncResponse +import com.bitwarden.network.service.SyncService +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.auth.manager.UserLogoutManager +import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason +import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult +import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit + +class VaultSyncManagerTest { + + private val clock: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) + private val syncService: SyncService = mockk { + coEvery { + getAccountRevisionDateMillis() + } returns clock.instant().plus(1, ChronoUnit.MINUTES).toEpochMilli().asSuccess() + } + private val fakeAuthDiskSource = FakeAuthDiskSource() + private val settingsDiskSource = mockk { + every { getLastSyncTime(userId = any()) } returns clock.instant() + every { storeLastSyncTime(userId = any(), lastSyncTime = any()) } just runs + } + private val mutableGetCiphersFlow: MutableStateFlow> = + MutableStateFlow(listOf(createMockCipher(1))) + private val vaultDiskSource: VaultDiskSource = mockk { + coEvery { resyncVaultData(any()) } just runs + every { getCiphersFlow(any()) } returns mutableGetCiphersFlow + } + private val vaultSdkSource: VaultSdkSource = mockk { + every { clearCrypto(userId = any()) } just runs + } + private val userLogoutManager: UserLogoutManager = mockk { + every { softLogout(any(), any()) } just runs + } + private val vaultSyncManager = VaultSyncManagerImpl( + syncService = syncService, + settingsDiskSource = settingsDiskSource, + authDiskSource = fakeAuthDiskSource, + vaultDiskSource = vaultDiskSource, + vaultSdkSource = vaultSdkSource, + userLogoutManager = userLogoutManager, + clock = clock, + ) + + @Test + fun `sync with forced should skip checks and call the syncService sync`() = runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { syncService.sync() } returns Throwable("failure").asFailure() + + vaultSyncManager.sync(userId = "mockId-1", forced = true) + + coVerify(exactly = 0) { + syncService.getAccountRevisionDateMillis() + } + coVerify(exactly = 1) { + syncService.sync() + } + } + + @Suppress("MaxLineLength") + @Test + fun `sync with syncService Success should unlock the vault for orgs if necessary and update AuthDiskSource and VaultDiskSource`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = "mockId-1" + val mockSyncResponse = createMockSyncResponse(number = 1) + coEvery { syncService.sync() } returns mockSyncResponse.asSuccess() + coEvery { + vaultSdkSource.initializeOrganizationCrypto( + userId = userId, + request = InitOrgCryptoRequest( + organizationKeys = createMockOrganizationKeys(1), + ), + ) + } returns InitializeCryptoResult.Success.asSuccess() + coEvery { + vaultDiskSource.replaceVaultData( + userId = MOCK_USER_STATE.activeUserId, + vault = mockSyncResponse, + ) + } just runs + every { + settingsDiskSource.storeLastSyncTime(MOCK_USER_STATE.activeUserId, clock.instant()) + } just runs + + vaultSyncManager.sync( + userId = MOCK_USER_STATE.activeUserId, + forced = false, + ) + + val updatedUserState = MOCK_USER_STATE + .copy( + accounts = mapOf( + "mockId-1" to MOCK_ACCOUNT.copy( + profile = MOCK_PROFILE.copy( + avatarColorHex = "mockAvatarColor-1", + stamp = "mockSecurityStamp-1", + ), + ), + ), + ) + fakeAuthDiskSource.assertUserState( + userState = updatedUserState, + ) + fakeAuthDiskSource.assertUserKey( + userId = "mockId-1", + userKey = "mockKey-1", + ) + fakeAuthDiskSource.assertPrivateKey( + userId = "mockId-1", + privateKey = "mockPrivateKey-1", + ) + fakeAuthDiskSource.assertOrganizationKeys( + userId = "mockId-1", + organizationKeys = mapOf("mockId-1" to "mockKey-1"), + ) + fakeAuthDiskSource.assertOrganizations( + userId = "mockId-1", + organizations = listOf(createMockOrganization(number = 1)), + ) + fakeAuthDiskSource.assertPolicies( + userId = "mockId-1", + policies = listOf(createMockPolicy(number = 1)), + ) + fakeAuthDiskSource.assertShouldUseKeyConnector( + userId = "mockId-1", + shouldUseKeyConnector = false, + ) + coVerify { + vaultDiskSource.replaceVaultData( + userId = MOCK_USER_STATE.activeUserId, + vault = mockSyncResponse, + ) + vaultSdkSource.initializeOrganizationCrypto( + userId = userId, + request = InitOrgCryptoRequest( + organizationKeys = createMockOrganizationKeys(1), + ), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `sync with syncService Success with a different security stamp should logout and return early`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = "mockId-1" + val mockSyncResponse = createMockSyncResponse(number = 1) + coEvery { syncService.sync() } returns mockSyncResponse + .copy(profile = createMockProfile(number = 1).copy(securityStamp = "newStamp")) + .asSuccess() + + coEvery { + vaultSdkSource.initializeOrganizationCrypto( + userId = userId, + request = InitOrgCryptoRequest( + organizationKeys = createMockOrganizationKeys(1), + ), + ) + } returns InitializeCryptoResult.Success.asSuccess() + + vaultSyncManager.sync( + userId = MOCK_USER_STATE.activeUserId, + forced = false, + ) + + coVerify(exactly = 1) { + userLogoutManager.softLogout(userId = userId, reason = LogoutReason.SecurityStamp) + } + + coVerify(exactly = 0) { + vaultDiskSource.replaceVaultData( + userId = MOCK_USER_STATE.activeUserId, + vault = any(), + ) + vaultSdkSource.initializeOrganizationCrypto( + userId = userId, + request = InitOrgCryptoRequest( + organizationKeys = createMockOrganizationKeys(1), + ), + ) + } + } + + @Test + fun `sync should return error when getAccountRevisionDateMillis fails`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val throwable = Throwable() + coEvery { + syncService.getAccountRevisionDateMillis() + } returns throwable.asFailure() + val syncResult = vaultSyncManager.sync( + userId = MOCK_USER_STATE.activeUserId, + forced = false, + ) + assertEquals( + SyncVaultDataResult.Error(throwable = throwable), + syncResult, + ) + } + + @Test + fun `sync when the last sync time is more recent than the revision date should not sync `() = + runTest { + val userId = "mockId-1" + fakeAuthDiskSource.userState = MOCK_USER_STATE + every { + settingsDiskSource.getLastSyncTime(userId = userId) + } returns clock.instant().plus(2, ChronoUnit.MINUTES) + + vaultSyncManager.sync( + userId = userId, + forced = false, + ) + + coVerify(exactly = 0) { syncService.sync() } + } +} + +private val MOCK_PROFILE = AccountJson.Profile( + userId = "mockId-1", + 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 = "mockId-1", + accounts = mapOf( + "mockId-1" 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 06cb112468..cc13f13d02 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 @@ -6,7 +6,6 @@ import app.cash.turbine.test import app.cash.turbine.turbineScope import com.bitwarden.collections.CollectionView import com.bitwarden.core.DateTime -import com.bitwarden.core.InitOrgCryptoRequest import com.bitwarden.core.InitUserCryptoMethod import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow @@ -29,17 +28,12 @@ import com.bitwarden.network.model.createMockCollection import com.bitwarden.network.model.createMockDomains import com.bitwarden.network.model.createMockFileSendResponseJson import com.bitwarden.network.model.createMockFolder -import com.bitwarden.network.model.createMockOrganization import com.bitwarden.network.model.createMockOrganizationKeys -import com.bitwarden.network.model.createMockPolicy -import com.bitwarden.network.model.createMockProfile import com.bitwarden.network.model.createMockSend import com.bitwarden.network.model.createMockSendJsonRequest -import com.bitwarden.network.model.createMockSyncResponse import com.bitwarden.network.service.CiphersService import com.bitwarden.network.service.FolderService import com.bitwarden.network.service.SendsService -import com.bitwarden.network.service.SyncService import com.bitwarden.sdk.Fido2CredentialStore import com.bitwarden.send.SendType import com.bitwarden.send.SendView @@ -53,8 +47,6 @@ 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.auth.manager.UserLogoutManager -import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.error.MissingPropertyException @@ -70,7 +62,6 @@ import com.x8bit.bitwarden.data.platform.manager.model.SyncSendDeleteData import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource -import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockAccount import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherListView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView @@ -86,6 +77,8 @@ import com.x8bit.bitwarden.data.vault.manager.CipherManager import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManager +import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager +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.CreateSendResult @@ -97,7 +90,6 @@ import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.ImportCxfPayloadResult import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.SendData -import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData @@ -156,21 +148,12 @@ class VaultRepositoryTest { ZoneOffset.UTC, ) private val dispatcherManager: DispatcherManager = FakeDispatcherManager() - private val userLogoutManager: UserLogoutManager = mockk { - every { softLogout(any(), any()) } just runs - } private val fileManager: FileManager = mockk { coEvery { delete(*anyVararg()) } just runs } private val fakeAuthDiskSource = FakeAuthDiskSource() private val settingsDiskSource = mockk { every { getLastSyncTime(userId = any()) } returns clock.instant() - every { storeLastSyncTime(userId = any(), lastSyncTime = any()) } just runs - } - private val syncService: SyncService = mockk { - coEvery { - getAccountRevisionDateMillis() - } returns clock.instant().plus(1, ChronoUnit.MINUTES).toEpochMilli().asSuccess() } private val sendsService: SendsService = mockk() private val ciphersService: CiphersService = mockk() @@ -232,9 +215,9 @@ class VaultRepositoryTest { every { syncFolderDeleteFlow } returns mutableSyncFolderDeleteFlow every { syncFolderUpsertFlow } returns mutableSyncFolderUpsertFlow } + private val vaultSyncManager: VaultSyncManager = mockk() private val vaultRepository = VaultRepositoryImpl( - syncService = syncService, sendsService = sendsService, ciphersService = ciphersService, folderService = folderService, @@ -249,9 +232,9 @@ class VaultRepositoryTest { cipherManager = cipherManager, fileManager = fileManager, clock = clock, - userLogoutManager = userLogoutManager, databaseSchemeManager = databaseSchemeManager, reviewPromptManager = reviewPromptManager, + vaultSyncManager = vaultSyncManager, ) @BeforeEach @@ -283,13 +266,13 @@ class VaultRepositoryTest { @Test fun `userSwitchingChangesFlow should cancel any pending sync call`() = runTest { fakeAuthDiskSource.userState = MOCK_USER_STATE - coEvery { syncService.sync() } just awaits + coEvery { vaultSyncManager.sync(any(), any()) } just awaits vaultRepository.sync() vaultRepository.sync() coVerify(exactly = 1) { // Despite being called twice, we only allow 1 sync - syncService.sync() + vaultSyncManager.sync(any(), any()) } fakeAuthDiskSource.userState = UserStateJson( @@ -297,10 +280,10 @@ class VaultRepositoryTest { accounts = mapOf("mockId-2" to mockk()), ) vaultRepository.sync() - coVerify(exactly = 2) { + coVerify { // A second sync should have happened now since it was cancelled by the userState change - syncService.getAccountRevisionDateMillis() - syncService.sync() + vaultSyncManager.sync(userId = "mockId-1", forced = any()) + vaultSyncManager.sync(userId = "mockId-2", forced = any()) } } @@ -815,227 +798,108 @@ class VaultRepositoryTest { @Test fun `databaseSchemeChangeFlow should trigger sync on emission`() = runTest { fakeAuthDiskSource.userState = MOCK_USER_STATE - coEvery { syncService.sync() } just awaits + coEvery { vaultSyncManager.sync(any(), any()) } just awaits mutableDatabaseSchemeChangeFlow.tryEmit(Unit) - coVerify(exactly = 1) { syncService.sync() } + coVerify(exactly = 1) { vaultSyncManager.sync(any(), any()) } } @Test - fun `sync with forced should skip checks and call the syncService sync`() { - fakeAuthDiskSource.userState = MOCK_USER_STATE - coEvery { syncService.sync() } returns Throwable("failure").asFailure() - - vaultRepository.sync(forced = true) - - coVerify(exactly = 0) { - syncService.getAccountRevisionDateMillis() - } - coVerify(exactly = 1) { - syncService.sync() - } - } - - @Suppress("MaxLineLength") - @Test - fun `sync with syncService Success should unlock the vault for orgs if necessary and update AuthDiskSource and VaultDiskSource`() = + fun `sync should update DataStateFlow with an Error when vaultSyncManager result is Error`() = runTest { fakeAuthDiskSource.userState = MOCK_USER_STATE - val userId = "mockId-1" - val mockSyncResponse = createMockSyncResponse(number = 1) - coEvery { syncService.sync() } returns mockSyncResponse.asSuccess() + val mockException = IllegalStateException("sad") coEvery { - vaultSdkSource.initializeOrganizationCrypto( - userId = userId, - request = InitOrgCryptoRequest( - organizationKeys = createMockOrganizationKeys(1), - ), - ) - } returns InitializeCryptoResult.Success.asSuccess() - coEvery { - vaultDiskSource.replaceVaultData( + vaultSyncManager.sync( userId = MOCK_USER_STATE.activeUserId, - vault = mockSyncResponse, + forced = false, ) - } just runs - every { - settingsDiskSource.storeLastSyncTime(MOCK_USER_STATE.activeUserId, clock.instant()) - } just runs + } returns SyncVaultDataResult.Error(throwable = mockException) vaultRepository.sync() - val updatedUserState = MOCK_USER_STATE - .copy( - accounts = mapOf( - "mockId-1" to MOCK_ACCOUNT.copy( - profile = MOCK_PROFILE.copy( - avatarColorHex = "mockAvatarColor-1", - stamp = "mockSecurityStamp-1", - ), - ), - ), - ) - fakeAuthDiskSource.assertUserState( - userState = updatedUserState, + assertEquals( + DataState.Error(mockException), + vaultRepository.decryptCipherListResultStateFlow.value, ) - fakeAuthDiskSource.assertUserKey( - userId = "mockId-1", - userKey = "mockKey-1", + assertEquals( + DataState.Error>(mockException), + vaultRepository.collectionsStateFlow.value, ) - fakeAuthDiskSource.assertPrivateKey( - userId = "mockId-1", - privateKey = "mockPrivateKey-1", + assertEquals( + DataState.Error(mockException), + vaultRepository.domainsStateFlow.value, ) - fakeAuthDiskSource.assertOrganizationKeys( - userId = "mockId-1", - organizationKeys = mapOf("mockId-1" to "mockKey-1"), + assertEquals( + DataState.Error>(mockException), + vaultRepository.foldersStateFlow.value, ) - fakeAuthDiskSource.assertOrganizations( - userId = "mockId-1", - organizations = listOf(createMockOrganization(number = 1)), + assertEquals( + DataState.Error(mockException), + vaultRepository.sendDataStateFlow.value, ) - fakeAuthDiskSource.assertPolicies( - userId = "mockId-1", - policies = listOf(createMockPolicy(number = 1)), - ) - fakeAuthDiskSource.assertShouldUseKeyConnector( - userId = "mockId-1", - shouldUseKeyConnector = false, - ) - coVerify { - vaultDiskSource.replaceVaultData( - userId = MOCK_USER_STATE.activeUserId, - vault = mockSyncResponse, - ) - vaultSdkSource.initializeOrganizationCrypto( - userId = userId, - request = InitOrgCryptoRequest( - organizationKeys = createMockOrganizationKeys(1), - ), - ) - } } @Suppress("MaxLineLength") @Test - fun `sync with syncService Success with a different security stamp should logout and return early`() = + fun `sync should update vaultDataStateFlow with an Error when vaultSyncManager result is Error`() = runTest { fakeAuthDiskSource.userState = MOCK_USER_STATE - val userId = "mockId-1" - val mockSyncResponse = createMockSyncResponse(number = 1) - coEvery { syncService.sync() } returns mockSyncResponse - .copy(profile = createMockProfile(number = 1).copy(securityStamp = "newStamp")) - .asSuccess() - + val mockException = IllegalStateException("sad") coEvery { - vaultSdkSource.initializeOrganizationCrypto( - userId = userId, - request = InitOrgCryptoRequest( - organizationKeys = createMockOrganizationKeys(1), - ), - ) - } returns InitializeCryptoResult.Success.asSuccess() + vaultSyncManager.sync(any(), any()) + } returns SyncVaultDataResult.Error(mockException) + setupVaultDiskSourceFlows() + + vaultRepository + .vaultDataStateFlow + .test { + assertEquals(DataState.Loading, awaitItem()) + vaultRepository.sync() + assertEquals(DataState.Error(mockException), awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `sync should update DataStateFlows to NoNetwork when vaultSyncManager result is Error with `() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { + vaultSyncManager.sync(any(), any()) + } returns SyncVaultDataResult.Error(throwable = UnknownHostException()) vaultRepository.sync() - coVerify(exactly = 1) { - userLogoutManager.softLogout(userId = userId, reason = LogoutReason.SecurityStamp) - } - - coVerify(exactly = 0) { - vaultDiskSource.replaceVaultData( - userId = MOCK_USER_STATE.activeUserId, - vault = any(), - ) - vaultSdkSource.initializeOrganizationCrypto( - userId = userId, - request = InitOrgCryptoRequest( - organizationKeys = createMockOrganizationKeys(1), - ), - ) - } + assertEquals( + DataState.NoNetwork(data = null), + vaultRepository.decryptCipherListResultStateFlow.value, + ) + assertEquals( + DataState.NoNetwork(data = null), + vaultRepository.collectionsStateFlow.value, + ) + assertEquals( + DataState.NoNetwork(data = null), + vaultRepository.domainsStateFlow.value, + ) + assertEquals( + DataState.NoNetwork(data = null), + vaultRepository.foldersStateFlow.value, + ) + assertEquals( + DataState.NoNetwork(data = null), + vaultRepository.sendDataStateFlow.value, + ) } - @Test - fun `sync with syncService Failure should update DataStateFlow with an Error`() = runTest { - fakeAuthDiskSource.userState = MOCK_USER_STATE - val mockException = IllegalStateException("sad") - coEvery { syncService.sync() } returns mockException.asFailure() - - vaultRepository.sync() - - assertEquals( - DataState.Error(mockException), - vaultRepository.decryptCipherListResultStateFlow.value, - ) - assertEquals( - DataState.Error>(mockException), - vaultRepository.collectionsStateFlow.value, - ) - assertEquals( - DataState.Error(mockException), - vaultRepository.domainsStateFlow.value, - ) - assertEquals( - DataState.Error>(mockException), - vaultRepository.foldersStateFlow.value, - ) - assertEquals( - DataState.Error(mockException), - vaultRepository.sendDataStateFlow.value, - ) - } - - @Test - fun `sync with syncService Failure should update vaultDataStateFlow with an Error`() = runTest { - fakeAuthDiskSource.userState = MOCK_USER_STATE - val mockException = IllegalStateException("sad") - coEvery { syncService.sync() } returns mockException.asFailure() - setupVaultDiskSourceFlows() - - vaultRepository - .vaultDataStateFlow - .test { - assertEquals(DataState.Loading, awaitItem()) - vaultRepository.sync() - assertEquals(DataState.Error(mockException), awaitItem()) - } - } - - @Test - fun `sync with NoNetwork should update DataStateFlows to NoNetwork`() = runTest { - fakeAuthDiskSource.userState = MOCK_USER_STATE - coEvery { syncService.sync() } returns UnknownHostException().asFailure() - - vaultRepository.sync() - - assertEquals( - DataState.NoNetwork(data = null), - vaultRepository.decryptCipherListResultStateFlow.value, - ) - assertEquals( - DataState.NoNetwork(data = null), - vaultRepository.collectionsStateFlow.value, - ) - assertEquals( - DataState.NoNetwork(data = null), - vaultRepository.domainsStateFlow.value, - ) - assertEquals( - DataState.NoNetwork(data = null), - vaultRepository.foldersStateFlow.value, - ) - assertEquals( - DataState.NoNetwork(data = null), - vaultRepository.sendDataStateFlow.value, - ) - } - @Test fun `sync with NoNetwork should update vaultDataStateFlow to NoNetwork`() = runTest { fakeAuthDiskSource.userState = MOCK_USER_STATE - coEvery { syncService.sync() } returns UnknownHostException().asFailure() + coEvery { + vaultSyncManager.sync(any(), any()) + } returns SyncVaultDataResult.Error(UnknownHostException()) setupVaultDiskSourceFlows() vaultRepository @@ -1054,7 +918,12 @@ class VaultRepositoryTest { fakeAuthDiskSource.userState = MOCK_USER_STATE val userId = "mockId-1" setVaultToUnlocked(userId = userId) - coEvery { syncService.sync() } returns UnknownHostException().asFailure() + coEvery { + vaultSyncManager.sync( + userId = userId, + forced = false, + ) + } returns SyncVaultDataResult.Error(throwable = UnknownHostException()) val sendsFlow = bufferedMutableSharedFlow>() setupVaultDiskSourceFlows(sendsFlow = sendsFlow) coEvery { @@ -1092,11 +961,11 @@ class VaultRepositoryTest { every { settingsDiskSource.getLastSyncTime(userId = userId) } returns null - coEvery { syncService.sync() } just awaits + coEvery { vaultSyncManager.sync(userId = userId, forced = false) } just awaits vaultRepository.syncIfNecessary() - coVerify { syncService.sync() } + coVerify { vaultSyncManager.sync(userId = userId, forced = false) } } @Suppress("MaxLineLength") @@ -1107,11 +976,11 @@ class VaultRepositoryTest { every { settingsDiskSource.getLastSyncTime(userId = userId) } returns clock.instant().minus(31, ChronoUnit.MINUTES) - coEvery { syncService.sync() } just awaits + coEvery { vaultSyncManager.sync(userId = userId, forced = false) } just awaits vaultRepository.syncIfNecessary() - coVerify { syncService.sync() } + coVerify { vaultSyncManager.sync(userId = userId, forced = false) } } @Suppress("MaxLineLength") @@ -1122,11 +991,11 @@ class VaultRepositoryTest { every { settingsDiskSource.getLastSyncTime(userId = userId) } returns clock.instant().minus(29, ChronoUnit.MINUTES) - coEvery { syncService.sync() } just awaits + coEvery { vaultSyncManager.sync(userId = userId, forced = false) } just awaits vaultRepository.syncIfNecessary() - coVerify(exactly = 0) { syncService.sync() } + coVerify(exactly = 0) { vaultSyncManager.sync(userId = any(), forced = any()) } } @Test @@ -1137,33 +1006,13 @@ class VaultRepositoryTest { settingsDiskSource.getLastSyncTime(userId = userId) } returns clock.instant().minus(1, ChronoUnit.MINUTES) - coEvery { syncService.sync() } just awaits + coEvery { vaultSyncManager.sync(userId = userId, forced = false) } just awaits vaultRepository.sync() - coVerify { syncService.sync() } + coVerify { vaultSyncManager.sync(userId = userId, forced = false) } } - @Test - fun `sync when the last sync time is more recent than the revision date should not sync `() = - runTest { - val userId = "mockId-1" - fakeAuthDiskSource.userState = MOCK_USER_STATE - every { - settingsDiskSource.getLastSyncTime(userId = userId) - } returns clock.instant().plus(2, ChronoUnit.MINUTES) - - vaultRepository.sync() - - verify(exactly = 1) { - settingsDiskSource.storeLastSyncTime( - userId = userId, - lastSyncTime = clock.instant(), - ) - } - coVerify(exactly = 0) { syncService.sync() } - } - @Test fun `lockVaultForCurrentUser should delegate to the VaultLockManager`() { vaultRepository.lockVaultForCurrentUser(isUserInitiated = true) @@ -1946,7 +1795,9 @@ class VaultRepositoryTest { val folderIdString = "mockId-$folderId" val throwable = Throwable("Fail") fakeAuthDiskSource.userState = MOCK_USER_STATE - coEvery { syncService.sync() } returns throwable.asFailure() + coEvery { + vaultSyncManager.sync(any(), any()) + } returns SyncVaultDataResult.Error(throwable) setupVaultDiskSourceFlows() vaultRepository.getVaultItemStateFlow(folderIdString).test { @@ -1956,7 +1807,7 @@ class VaultRepositoryTest { } coVerify(exactly = 1) { - syncService.sync() + vaultSyncManager.sync(any(), any()) } } @@ -1966,7 +1817,9 @@ class VaultRepositoryTest { val itemId = 1234 val itemIdString = "mockId-$itemId" fakeAuthDiskSource.userState = MOCK_USER_STATE - coEvery { syncService.sync() } returns UnknownHostException().asFailure() + coEvery { + vaultSyncManager.sync(any(), any()) + } returns SyncVaultDataResult.Error(UnknownHostException()) setupVaultDiskSourceFlows() vaultRepository.getVaultItemStateFlow(itemIdString).test { @@ -1976,7 +1829,7 @@ class VaultRepositoryTest { } coVerify(exactly = 1) { - syncService.sync() + vaultSyncManager.sync(any(), any()) } } @@ -2093,7 +1946,9 @@ class VaultRepositoryTest { val folderId = 1234 val folderIdString = "mockId-$folderId" fakeAuthDiskSource.userState = MOCK_USER_STATE - coEvery { syncService.sync() } returns UnknownHostException().asFailure() + coEvery { + vaultSyncManager.sync(any(), any()) + } returns SyncVaultDataResult.Error(UnknownHostException()) setupVaultDiskSourceFlows() vaultRepository.getVaultFolderStateFlow(folderIdString).test { @@ -2103,7 +1958,7 @@ class VaultRepositoryTest { } coVerify(exactly = 1) { - syncService.sync() + vaultSyncManager.sync(any(), any()) } } @@ -2114,7 +1969,9 @@ class VaultRepositoryTest { val folderIdString = "mockId-$folderId" val throwable = Throwable("Fail") fakeAuthDiskSource.userState = MOCK_USER_STATE - coEvery { syncService.sync() } returns throwable.asFailure() + coEvery { + vaultSyncManager.sync(any(), any()) + } returns SyncVaultDataResult.Error(throwable) setupVaultDiskSourceFlows() vaultRepository.getVaultFolderStateFlow(folderIdString).test { @@ -2124,7 +1981,7 @@ class VaultRepositoryTest { } coVerify(exactly = 1) { - syncService.sync() + vaultSyncManager.sync(any(), any()) } } @@ -2169,7 +2026,9 @@ class VaultRepositoryTest { runTest { val sendId = 1234 fakeAuthDiskSource.userState = MOCK_USER_STATE - coEvery { syncService.sync() } returns UnknownHostException().asFailure() + coEvery { + vaultSyncManager.sync(any(), any()) + } returns SyncVaultDataResult.Error(UnknownHostException()) setupVaultDiskSourceFlows() vaultRepository.getSendStateFlow("mockId-$sendId").test { @@ -2179,7 +2038,7 @@ class VaultRepositoryTest { } coVerify(exactly = 1) { - syncService.sync() + vaultSyncManager.sync(any(), any()) } } @@ -2189,7 +2048,9 @@ class VaultRepositoryTest { val sendId = 1234 val throwable = Throwable("Fail") fakeAuthDiskSource.userState = MOCK_USER_STATE - coEvery { syncService.sync() } returns throwable.asFailure() + coEvery { + vaultSyncManager.sync(any(), any()) + } returns SyncVaultDataResult.Error(throwable) setupVaultDiskSourceFlows() vaultRepository.getSendStateFlow("mockId-$sendId").test { @@ -2199,7 +2060,7 @@ class VaultRepositoryTest { } coVerify(exactly = 1) { - syncService.sync() + vaultSyncManager.sync(any(), any()) } } @@ -3175,29 +3036,9 @@ class VaultRepositoryTest { val userId = "mockId-1" setVaultToUnlocked(userId = userId) - val mockSyncResponse = createMockSyncResponse(number = 1) - coEvery { syncService.sync() } returns mockSyncResponse.asSuccess() coEvery { - vaultSdkSource.initializeOrganizationCrypto( - userId = userId, - request = InitOrgCryptoRequest( - organizationKeys = createMockOrganizationKeys(1), - ), - ) - } returns InitializeCryptoResult.Success.asSuccess() - coEvery { - vaultDiskSource.replaceVaultData( - userId = MOCK_USER_STATE.activeUserId, - vault = mockSyncResponse, - ) - } just runs - - every { - settingsDiskSource.storeLastSyncTime( - MOCK_USER_STATE.activeUserId, - clock.instant(), - ) - } just runs + vaultSyncManager.sync(userId = userId, forced = false) + } returns SyncVaultDataResult.Success(itemsAvailable = true) val stateFlow = MutableStateFlow>( DataState.Loading, @@ -3243,28 +3084,9 @@ class VaultRepositoryTest { val userId = "mockId-1" setVaultToUnlocked(userId = userId) - val mockSyncResponse = createMockSyncResponse(number = 1) - coEvery { syncService.sync() } returns mockSyncResponse.asSuccess() coEvery { - vaultSdkSource.initializeOrganizationCrypto( - userId = userId, - request = InitOrgCryptoRequest( - organizationKeys = createMockOrganizationKeys(1), - ), - ) - } returns InitializeCryptoResult.Success.asSuccess() - coEvery { - vaultDiskSource.replaceVaultData( - userId = MOCK_USER_STATE.activeUserId, - vault = mockSyncResponse, - ) - } just runs - every { - settingsDiskSource.storeLastSyncTime( - MOCK_USER_STATE.activeUserId, - clock.instant(), - ) - } just runs + vaultSyncManager.sync(any(), any()) + } returns SyncVaultDataResult.Success(itemsAvailable = true) val stateFlow = MutableStateFlow>>( DataState.Loading, @@ -3308,11 +3130,11 @@ class VaultRepositoryTest { every { settingsDiskSource.getLastSyncTime(userId = userId) } returns null - coEvery { syncService.sync() } just awaits + coEvery { vaultSyncManager.sync(any(), any()) } just awaits mutableFullSyncFlow.tryEmit(Unit) - coVerify { syncService.sync() } + coVerify { vaultSyncManager.sync(any(), any()) } } @Test @@ -4393,31 +4215,12 @@ class VaultRepositoryTest { runTest { fakeAuthDiskSource.userState = MOCK_USER_STATE val userId = "mockId-1" - val mockSyncResponse = createMockSyncResponse(number = 1) coEvery { - syncService.sync() - } returns mockSyncResponse.asSuccess() - coEvery { - vaultSdkSource.initializeOrganizationCrypto( + vaultSyncManager.sync( userId = userId, - request = InitOrgCryptoRequest( - organizationKeys = createMockOrganizationKeys(1), - ), + forced = false, ) - } returns InitializeCryptoResult.Success.asSuccess() - coEvery { - vaultDiskSource.replaceVaultData( - userId = MOCK_USER_STATE.activeUserId, - vault = mockSyncResponse, - ) - } just runs - - every { - settingsDiskSource.storeLastSyncTime( - MOCK_USER_STATE.activeUserId, - clock.instant(), - ) - } just runs + } returns SyncVaultDataResult.Success(itemsAvailable = true) val syncResult = vaultRepository.syncForResult() assertEquals(SyncVaultDataResult.Success(itemsAvailable = true), syncResult) @@ -4429,58 +4232,21 @@ class VaultRepositoryTest { runTest { fakeAuthDiskSource.userState = MOCK_USER_STATE val userId = "mockId-1" - val mockSyncResponse = createMockSyncResponse(number = 1).copy(ciphers = emptyList()) coEvery { - syncService.sync() - } returns mockSyncResponse.asSuccess() - coEvery { - vaultSdkSource.initializeOrganizationCrypto( - userId = userId, - request = InitOrgCryptoRequest( - organizationKeys = createMockOrganizationKeys(1), - ), - ) - } returns InitializeCryptoResult.Success.asSuccess() - coEvery { - vaultDiskSource.replaceVaultData( - userId = MOCK_USER_STATE.activeUserId, - vault = mockSyncResponse, - ) - } just runs - - every { - settingsDiskSource.storeLastSyncTime( - MOCK_USER_STATE.activeUserId, - clock.instant(), - ) - } just runs + vaultSyncManager.sync(userId = userId, forced = false) + } returns SyncVaultDataResult.Success(itemsAvailable = false) val syncResult = vaultRepository.syncForResult() assertEquals(SyncVaultDataResult.Success(itemsAvailable = false), syncResult) } @Test - fun `syncForResult should return error when getAccountRevisionDateMillis fails`() = - runTest { - fakeAuthDiskSource.userState = MOCK_USER_STATE - val throwable = Throwable() - coEvery { - syncService.getAccountRevisionDateMillis() - } returns throwable.asFailure() - val syncResult = vaultRepository.syncForResult() - assertEquals( - SyncVaultDataResult.Error(throwable = throwable), - syncResult, - ) - } - - @Test - fun `syncForResult should return error when sync fails`() = runTest { + fun `syncForResult should return error when VaultSyncManager sync result is Error`() = runTest { fakeAuthDiskSource.userState = MOCK_USER_STATE val throwable = Throwable() coEvery { - syncService.sync() - } returns throwable.asFailure() + vaultSyncManager.sync(any(), any()) + } returns SyncVaultDataResult.Error(throwable) val syncResult = vaultRepository.syncForResult() assertEquals( SyncVaultDataResult.Error(throwable = throwable), @@ -4488,30 +4254,6 @@ class VaultRepositoryTest { ) } - @Suppress("MaxLineLength") - @Test - fun `syncForResult when the last sync time is more recent than the revision date should return result from disk source data`() = - runTest { - val userId = "mockId-1" - fakeAuthDiskSource.userState = MOCK_USER_STATE - every { - settingsDiskSource.getLastSyncTime(userId = userId) - } returns clock.instant().plus(2, ChronoUnit.MINUTES) - mutableGetCiphersFlow.update { emptyList() } - val result = vaultRepository.syncForResult() - assertEquals( - SyncVaultDataResult.Success(itemsAvailable = false), - result, - ) - verify(exactly = 1) { - settingsDiskSource.storeLastSyncTime( - userId = userId, - lastSyncTime = clock.instant(), - ) - } - coVerify(exactly = 0) { syncService.sync() } - } - //region Helper functions /** @@ -4523,22 +4265,9 @@ class VaultRepositoryTest { mockPin: String = "1234", ) { val userId = "mockId-1" - val mockSyncResponse = createMockSyncResponse(number = 1) - coEvery { syncService.sync() } returns mockSyncResponse.asSuccess() coEvery { - vaultSdkSource.initializeOrganizationCrypto( - userId = userId, - request = InitOrgCryptoRequest( - organizationKeys = createMockOrganizationKeys(1), - ), - ) - } returns InitializeCryptoResult.Success.asSuccess() - coEvery { - vaultDiskSource.replaceVaultData( - userId = userId, - vault = mockSyncResponse, - ) - } just runs + vaultSyncManager.sync(any(), any()) + } returns SyncVaultDataResult.Success(itemsAvailable = true) coEvery { vaultSdkSource.decryptSendList( userId = userId, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModelTest.kt index c2b40c73f9..df3a4a8c76 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModelTest.kt @@ -9,8 +9,8 @@ import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository +import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult import com.x8bit.bitwarden.data.vault.repository.VaultRepository -import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManagerImpl import io.mockk.coEvery