Refactor Vault Sync Logic into VaultSyncManager (#5871)

This commit is contained in:
Patrick Honkonen 2025-09-16 12:44:52 -04:00 committed by GitHub
parent cfd0a5b8a5
commit f954b0b941
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 679 additions and 563 deletions

View File

@ -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!")

View File

@ -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
}

View File

@ -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,
)
}
}
}

View File

@ -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,
)
}

View File

@ -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()
}

View File

@ -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

View File

@ -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<DataState<DecryptCipherListResult>> =
@ -1500,89 +1437,16 @@ 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)
): SyncVaultDataResult =
vaultSyncManager
.sync(userId = userId, forced = forced)
.also { result ->
if (result is SyncVaultDataResult.Error) {
updateVaultStateFlowsToError(throwable = result.throwable)
}
},
onFailure = {
updateVaultStateFlowsToError(throwable = it)
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(
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)
},
)
}
}

View File

@ -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,
)
}

View File

@ -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

View File

@ -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<SettingsDiskSource> {
every { getLastSyncTime(userId = any()) } returns clock.instant()
every { storeLastSyncTime(userId = any(), lastSyncTime = any()) } just runs
}
private val mutableGetCiphersFlow: MutableStateFlow<List<SyncResponseJson.Cipher>> =
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,
),
)

View File

@ -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<SettingsDiskSource> {
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,153 +798,24 @@ 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()
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
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,
)
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()
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),
),
)
}
}
@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()
coEvery {
vaultSyncManager.sync(
userId = MOCK_USER_STATE.activeUserId,
forced = false,
)
} returns SyncVaultDataResult.Error(throwable = mockException)
vaultRepository.sync()
@ -987,11 +841,15 @@ class VaultRepositoryTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `sync with syncService Failure should update vaultDataStateFlow with an Error`() = runTest {
fun `sync should update vaultDataStateFlow with an Error when vaultSyncManager result is Error`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val mockException = IllegalStateException("sad")
coEvery { syncService.sync() } returns mockException.asFailure()
coEvery {
vaultSyncManager.sync(any(), any())
} returns SyncVaultDataResult.Error(mockException)
setupVaultDiskSourceFlows()
vaultRepository
@ -1003,10 +861,14 @@ class VaultRepositoryTest {
}
}
@Suppress("MaxLineLength")
@Test
fun `sync with NoNetwork should update DataStateFlows to NoNetwork`() = runTest {
fun `sync should update DataStateFlows to NoNetwork when vaultSyncManager result is Error with `() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery { syncService.sync() } returns UnknownHostException().asFailure()
coEvery {
vaultSyncManager.sync(any(), any())
} returns SyncVaultDataResult.Error(throwable = UnknownHostException())
vaultRepository.sync()
@ -1035,7 +897,9 @@ class VaultRepositoryTest {
@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<List<SyncResponseJson.Send>>()
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,31 +1006,11 @@ 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() }
}
@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() }
coVerify { vaultSyncManager.sync(userId = userId, forced = false) }
}
@Test
@ -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<VerificationCodeItem?>>(
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<List<VerificationCodeItem>>>(
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,44 +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 {
fun `syncForResult should return error when VaultSyncManager sync result is Error`() = runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val throwable = Throwable()
coEvery {
syncService.getAccountRevisionDateMillis()
} returns throwable.asFailure()
vaultSyncManager.sync(any(), any())
} returns SyncVaultDataResult.Error(throwable)
val syncResult = vaultRepository.syncForResult()
assertEquals(
SyncVaultDataResult.Error(throwable = throwable),
@ -4474,44 +4254,6 @@ class VaultRepositoryTest {
)
}
@Test
fun `syncForResult should return error when sync fails`() = runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val throwable = Throwable()
coEvery {
syncService.sync()
} returns throwable.asFailure()
val syncResult = vaultRepository.syncForResult()
assertEquals(
SyncVaultDataResult.Error(throwable = throwable),
syncResult,
)
}
@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,

View File

@ -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