Refactor cipher logic into CipherManager (#5898)

This commit is contained in:
David Perez 2025-09-17 14:51:44 -05:00 committed by GitHub
parent a39f83349f
commit 2756bd9fde
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 463 additions and 447 deletions

View File

@ -5,6 +5,7 @@ import androidx.core.net.toUri
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.network.model.AttachmentJsonResponse
import com.bitwarden.network.model.CreateCipherInOrganizationJsonRequest
import com.bitwarden.network.model.CreateCipherResponseJson
@ -17,7 +18,10 @@ import com.bitwarden.vault.CipherView
import com.bitwarden.vault.EncryptionContext
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
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.DownloadResult
@ -34,13 +38,18 @@ import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toNetworkAttachmentRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import retrofit2.HttpException
import java.io.File
import java.time.Clock
/**
* The default implementation of the [CipherManager].
*/
@Suppress("TooManyFunctions", "LongParameterList")
@Suppress("TooManyFunctions", "LongParameterList", "LargeClass")
class CipherManagerImpl(
private val fileManager: FileManager,
private val authDiskSource: AuthDiskSource,
@ -49,9 +58,24 @@ class CipherManagerImpl(
private val vaultSdkSource: VaultSdkSource,
private val clock: Clock,
private val reviewPromptManager: ReviewPromptManager,
dispatcherManager: DispatcherManager,
pushManager: PushManager,
) : CipherManager {
private val ioScope = CoroutineScope(dispatcherManager.io)
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
init {
pushManager
.syncCipherDeleteFlow
.onEach(::deleteCipher)
.launchIn(unconfinedScope)
pushManager
.syncCipherUpsertFlow
.onEach(::syncCipherIfNecessary)
.launchIn(ioScope)
}
override suspend fun createCipher(cipherView: CipherView): CreateCipherResult {
val userId = activeUserId
?: return CreateCipherResult.Error(
@ -641,4 +665,79 @@ class CipherManagerImpl(
}
return migratedCipherView.asSuccess()
}
/**
* Deletes the cipher specified by [syncCipherDeleteData] from disk.
*/
private suspend fun deleteCipher(syncCipherDeleteData: SyncCipherDeleteData) {
vaultDiskSource.deleteCipher(
userId = syncCipherDeleteData.userId,
cipherId = syncCipherDeleteData.cipherId,
)
}
/**
* Syncs an individual cipher contained in [syncCipherUpsertData] to disk if certain criteria
* are met. If the resource cannot be found cloud-side, and it was updated, delete it from disk
* for now.
*/
private suspend fun syncCipherIfNecessary(syncCipherUpsertData: SyncCipherUpsertData) {
val userId = activeUserId ?: return
val cipherId = syncCipherUpsertData.cipherId
val organizationId = syncCipherUpsertData.organizationId
val collectionIds = syncCipherUpsertData.collectionIds
val revisionDate = syncCipherUpsertData.revisionDate
val isUpdate = syncCipherUpsertData.isUpdate
// Return if local cipher is more recent
val localCipher = vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
if (localCipher != null &&
localCipher.revisionDate.toEpochSecond() > revisionDate.toEpochSecond()
) {
return
}
var shouldUpdate: Boolean
val shouldCheckCollections: Boolean
when {
isUpdate -> {
shouldUpdate = localCipher != null
shouldCheckCollections = true
}
collectionIds == null || organizationId == null -> {
shouldUpdate = localCipher == null
shouldCheckCollections = false
}
else -> {
shouldUpdate = false
shouldCheckCollections = true
}
}
if (!shouldUpdate && shouldCheckCollections && organizationId != null) {
// Check if there are any collections in common
shouldUpdate = vaultDiskSource
.getCollections(userId = userId)
.first()
.any { collectionIds?.contains(it.id) == true }
}
if (!shouldUpdate) return
ciphersService
.getCipher(cipherId = cipherId)
.fold(
onSuccess = { vaultDiskSource.saveCipher(userId = userId, cipher = it) },
onFailure = {
// Delete any updates if it's missing from the server
val httpException = it as? HttpException
@Suppress("MagicNumber")
if (httpException?.code() == 404 && isUpdate) {
vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId)
}
},
)
}
}

View File

@ -57,6 +57,8 @@ object VaultManagerModule {
fileManager: FileManager,
clock: Clock,
reviewPromptManager: ReviewPromptManager,
dispatcherManager: DispatcherManager,
pushManager: PushManager,
): CipherManager = CipherManagerImpl(
fileManager = fileManager,
authDiskSource = authDiskSource,
@ -65,6 +67,8 @@ object VaultManagerModule {
vaultSdkSource = vaultSdkSource,
clock = clock,
reviewPromptManager = reviewPromptManager,
dispatcherManager = dispatcherManager,
pushManager = pushManager,
)
@Provides

View File

@ -15,7 +15,6 @@ import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.network.model.UpdateFolderResponseJson
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.util.isNoConnectionError
import com.bitwarden.sdk.Fido2CredentialStore
@ -35,8 +34,6 @@ import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndLoggedIn
@ -102,7 +99,6 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.HttpException
import timber.log.Timber
import java.security.GeneralSecurityException
import java.time.Clock
@ -120,7 +116,6 @@ private const val STOP_TIMEOUT_DELAY_MS: Long = 1000L
*/
@Suppress("TooManyFunctions", "LongParameterList", "LargeClass")
class VaultRepositoryImpl(
private val ciphersService: CiphersService,
private val folderService: FolderService,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
@ -286,16 +281,6 @@ class VaultRepositoryImpl(
.onEach { sync(forced = false) }
.launchIn(unconfinedScope)
pushManager
.syncCipherDeleteFlow
.onEach(::deleteCipher)
.launchIn(unconfinedScope)
pushManager
.syncCipherUpsertFlow
.onEach(::syncCipherIfNecessary)
.launchIn(ioScope)
pushManager
.syncFolderDeleteFlow
.onEach(::deleteFolder)
@ -1065,82 +1050,6 @@ class VaultRepositoryImpl(
?: DataState.Loading
//region Push notification helpers
/**
* Deletes the cipher specified by [syncCipherDeleteData] from disk.
*/
private suspend fun deleteCipher(syncCipherDeleteData: SyncCipherDeleteData) {
vaultDiskSource.deleteCipher(
userId = syncCipherDeleteData.userId,
cipherId = syncCipherDeleteData.cipherId,
)
}
/**
* Syncs an individual cipher contained in [syncCipherUpsertData] to disk if certain criteria
* are met. If the resource cannot be found cloud-side, and it was updated, delete it from disk
* for now.
*/
private suspend fun syncCipherIfNecessary(syncCipherUpsertData: SyncCipherUpsertData) {
val userId = activeUserId ?: return
val cipherId = syncCipherUpsertData.cipherId
val organizationId = syncCipherUpsertData.organizationId
val collectionIds = syncCipherUpsertData.collectionIds
val revisionDate = syncCipherUpsertData.revisionDate
val isUpdate = syncCipherUpsertData.isUpdate
val localCipher = vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
// Return if local cipher is more recent
if (localCipher != null &&
localCipher.revisionDate.toEpochSecond() > revisionDate.toEpochSecond()
) {
return
}
var shouldUpdate: Boolean
val shouldCheckCollections: Boolean
when {
isUpdate -> {
shouldUpdate = localCipher != null
shouldCheckCollections = true
}
collectionIds == null || organizationId == null -> {
shouldUpdate = localCipher == null
shouldCheckCollections = false
}
else -> {
shouldUpdate = false
shouldCheckCollections = true
}
}
if (!shouldUpdate && shouldCheckCollections && organizationId != null) {
// Check if there are any collections in common
shouldUpdate = vaultDiskSource
.getCollections(userId = userId)
.first()
.any { collectionIds?.contains(it.id) == true }
}
if (!shouldUpdate) return
ciphersService
.getCipher(cipherId)
.fold(
onSuccess = { vaultDiskSource.saveCipher(userId, it) },
onFailure = {
// Delete any updates if it's missing from the server
val httpException = it as? HttpException
@Suppress("MagicNumber")
if (httpException?.code() == 404 && isUpdate) {
vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId)
}
},
)
}
/**
* Deletes the folder specified by [syncFolderDeleteData] from disk.

View File

@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.vault.repository.di
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.FolderService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
@ -34,7 +33,6 @@ object VaultRepositoryModule {
@Provides
@Singleton
fun providesVaultRepository(
ciphersService: CiphersService,
folderService: FolderService,
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
@ -51,7 +49,6 @@ object VaultRepositoryModule {
vaultSyncManager: VaultSyncManager,
credentialExchangeImportManager: CredentialExchangeImportManager,
): VaultRepository = VaultRepositoryImpl(
ciphersService = ciphersService,
folderService = folderService,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,

View File

@ -2,8 +2,10 @@ package com.x8bit.bitwarden.data.vault.manager
import android.net.Uri
import androidx.core.net.toUri
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager
import com.bitwarden.network.model.AttachmentJsonRequest
import com.bitwarden.network.model.CreateCipherInOrganizationJsonRequest
import com.bitwarden.network.model.CreateCipherResponseJson
@ -15,6 +17,7 @@ import com.bitwarden.network.model.createMockAttachment
import com.bitwarden.network.model.createMockAttachmentResponse
import com.bitwarden.network.model.createMockCipher
import com.bitwarden.network.model.createMockCipherJsonRequest
import com.bitwarden.network.model.createMockCollection
import com.bitwarden.network.model.createMockLogin
import com.bitwarden.network.service.CiphersService
import com.bitwarden.vault.Attachment
@ -27,7 +30,10 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
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.createMockAttachmentView
@ -60,16 +66,19 @@ import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkConstructor
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import retrofit2.HttpException
import java.io.File
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
@Suppress("LargeClass")
class CipherManagerTest {
@ -88,6 +97,12 @@ class CipherManagerTest {
private val reviewPromptManager: ReviewPromptManager = mockk {
every { registerAddCipherAction() } just runs
}
private val mutableSyncCipherDeleteFlow = bufferedMutableSharedFlow<SyncCipherDeleteData>()
private val mutableSyncCipherUpsertFlow = bufferedMutableSharedFlow<SyncCipherUpsertData>()
private val pushManager: PushManager = mockk {
every { syncCipherDeleteFlow } returns mutableSyncCipherDeleteFlow
every { syncCipherUpsertFlow } returns mutableSyncCipherUpsertFlow
}
private val cipherManager: CipherManager = CipherManagerImpl(
ciphersService = ciphersService,
@ -97,6 +112,8 @@ class CipherManagerTest {
fileManager = fileManager,
clock = clock,
reviewPromptManager = reviewPromptManager,
pushManager = pushManager,
dispatcherManager = FakeDispatcherManager(),
)
@BeforeEach
@ -2353,6 +2370,348 @@ class CipherManagerTest {
}
}
@Test
fun `syncCipherDeleteFlow should delete cipher from disk`() {
val userId = "mockId-1"
val cipherId = "mockId-1"
coEvery { vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId) } just runs
mutableSyncCipherDeleteFlow.tryEmit(
SyncCipherDeleteData(userId = userId, cipherId = cipherId),
)
coVerify { vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId) }
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow create with local cipher with no common collections should do nothing`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
val originalCipher = createMockCipher(
number = number,
revisionDate = ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES),
)
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
} returns originalCipher
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = false,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
}
coVerify(exactly = 0) {
ciphersService.getCipher(cipherId = any())
vaultDiskSource.saveCipher(userId = any(), cipher = any())
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow create with local cipher, and with common collections, should make a request and save cipher to disk`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
val collection = createMockCollection(number = number)
val originalCipher = createMockCipher(
number = number,
revisionDate = ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES),
)
val updatedCipher = mockk<SyncResponseJson.Cipher>()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
} returns originalCipher
coEvery {
vaultDiskSource.getCollections(userId = userId)
} returns MutableStateFlow(listOf(collection))
coEvery {
ciphersService.getCipher(cipherId = cipherId)
} returns updatedCipher.asSuccess()
coEvery {
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
} just runs
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = false,
collectionIds = listOf("mockId-1"),
organizationId = "mock-id",
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
ciphersService.getCipher(cipherId = cipherId)
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow update with no local cipher, but with common collections, should make a request save cipher to disk`() =
runTest {
val userId = MOCK_USER_STATE.activeUserId
val number = 1
val cipherId = "mockId-$number"
val originalCipher = createMockCipher(
number = number,
revisionDate = ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES),
)
val updatedCipher = mockk<SyncResponseJson.Cipher>()
val collection = createMockCollection(number = number)
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery { vaultDiskSource.getCipher(any(), any()) } returns originalCipher
coEvery {
vaultDiskSource.getCollections(userId = userId)
} returns MutableStateFlow(listOf(collection))
coEvery { ciphersService.getCipher(cipherId) } returns updatedCipher.asSuccess()
coEvery {
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
} just runs
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = true,
collectionIds = listOf("mockId-1"),
organizationId = "mock-id",
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
ciphersService.getCipher(cipherId = cipherId)
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
}
}
@Test
fun `syncCipherUpsertFlow update with no local cipher should do nothing`() = runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery { vaultDiskSource.getCipher(userId = userId, cipherId = cipherId) } returns null
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = true,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
}
coVerify(exactly = 0) {
ciphersService.getCipher(cipherId = any())
vaultDiskSource.saveCipher(userId = any(), cipher = any())
}
}
@Test
fun `syncCipherUpsertFlow update with more recent local cipher should do nothing`() = runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
val originalCipher = createMockCipher(
number = number,
revisionDate = ZonedDateTime.now(clock),
)
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
} returns originalCipher
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES),
isUpdate = true,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
}
coVerify(exactly = 0) {
ciphersService.getCipher(cipherId = any())
vaultDiskSource.saveCipher(userId = any(), cipher = any())
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow update failure with 404 code should make a request for a cipher and then delete it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
coEvery {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
} returns createMockCipher(number = number)
val response: HttpException = mockk {
every { code() } returns 404
}
coEvery { ciphersService.getCipher(cipherId = cipherId) } returns response.asFailure()
coEvery { vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId) } just runs
fakeAuthDiskSource.userState = MOCK_USER_STATE
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = true,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
ciphersService.getCipher(cipherId = cipherId)
vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId)
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow create failure with 404 code should make a request for a cipher and do nothing`() =
runTest {
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val response: HttpException = mockk {
every { code() } returns 404
}
coEvery { ciphersService.getCipher(cipherId = cipherId) } returns response.asFailure()
coEvery { vaultDiskSource.getCipher(userId = userId, cipherId = cipherId) } returns null
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = false,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
ciphersService.getCipher(cipherId = cipherId)
}
coVerify(exactly = 0) {
vaultDiskSource.deleteCipher(userId = any(), cipherId = any())
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow valid create success should make a request for a cipher and then store it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
val updatedCipher = mockk<SyncResponseJson.Cipher>()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
} returns null
coEvery {
ciphersService.getCipher(cipherId = cipherId)
} returns updatedCipher.asSuccess()
coEvery {
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
} just runs
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = false,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
ciphersService.getCipher(cipherId = cipherId)
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow valid update success should make a request for a cipher and then store it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
val originalCipher = mockk<SyncResponseJson.Cipher> {
every { revisionDate } returns ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES)
}
val updatedCipher = mockk<SyncResponseJson.Cipher>()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
} returns originalCipher
coEvery { ciphersService.getCipher(cipherId) } returns updatedCipher.asSuccess()
coEvery {
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
} just runs
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = true,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
ciphersService.getCipher(cipherId)
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
}
}
private fun setupMockUri(
url: String,
queryParams: Map<String, String> = emptyMap(),

View File

@ -24,7 +24,6 @@ import com.bitwarden.network.model.createMockDomains
import com.bitwarden.network.model.createMockFolder
import com.bitwarden.network.model.createMockOrganizationKeys
import com.bitwarden.network.model.createMockSend
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.FolderService
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.send.SendView
@ -44,8 +43,6 @@ import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
@ -111,7 +108,6 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import retrofit2.HttpException
import java.net.UnknownHostException
import java.security.GeneralSecurityException
import java.security.MessageDigest
@ -135,7 +131,6 @@ class VaultRepositoryTest {
private val settingsDiskSource = mockk<SettingsDiskSource> {
every { getLastSyncTime(userId = any()) } returns clock.instant()
}
private val ciphersService: CiphersService = mockk()
private val folderService: FolderService = mockk()
private val mutableGetCiphersFlow: MutableStateFlow<List<SyncResponseJson.Cipher>> =
MutableStateFlow(listOf(createMockCipher(1)))
@ -174,14 +169,10 @@ class VaultRepositoryTest {
every { databaseSchemeChangeFlow } returns mutableDatabaseSchemeChangeFlow
}
private val mutableFullSyncFlow = bufferedMutableSharedFlow<Unit>()
private val mutableSyncCipherDeleteFlow = bufferedMutableSharedFlow<SyncCipherDeleteData>()
private val mutableSyncCipherUpsertFlow = bufferedMutableSharedFlow<SyncCipherUpsertData>()
private val mutableSyncFolderDeleteFlow = bufferedMutableSharedFlow<SyncFolderDeleteData>()
private val mutableSyncFolderUpsertFlow = bufferedMutableSharedFlow<SyncFolderUpsertData>()
private val pushManager: PushManager = mockk {
every { fullSyncFlow } returns mutableFullSyncFlow
every { syncCipherDeleteFlow } returns mutableSyncCipherDeleteFlow
every { syncCipherUpsertFlow } returns mutableSyncCipherUpsertFlow
every { syncFolderDeleteFlow } returns mutableSyncFolderDeleteFlow
every { syncFolderUpsertFlow } returns mutableSyncFolderUpsertFlow
}
@ -189,7 +180,6 @@ class VaultRepositoryTest {
private val credentialExchangeImportManager: CredentialExchangeImportManager = mockk()
private val vaultRepository = VaultRepositoryImpl(
ciphersService = ciphersService,
folderService = folderService,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
@ -2543,348 +2533,6 @@ class VaultRepositoryTest {
coVerify { vaultSyncManager.sync(any(), any()) }
}
@Test
fun `syncCipherDeleteFlow should delete cipher from disk`() {
val userId = "mockId-1"
val cipherId = "mockId-1"
coEvery { vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId) } just runs
mutableSyncCipherDeleteFlow.tryEmit(
SyncCipherDeleteData(userId = userId, cipherId = cipherId),
)
coVerify { vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId) }
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow create with local cipher with no common collections should do nothing`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
val originalCipher = createMockCipher(
number = number,
revisionDate = ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES),
)
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
} returns originalCipher
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = false,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
}
coVerify(exactly = 0) {
ciphersService.getCipher(cipherId = any())
vaultDiskSource.saveCipher(userId = any(), cipher = any())
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow create with local cipher, and with common collections, should make a request and save cipher to disk`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
val collection = createMockCollection(number = number)
val originalCipher = createMockCipher(
number = number,
revisionDate = ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES),
)
val updatedCipher = mockk<SyncResponseJson.Cipher>()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
} returns originalCipher
coEvery {
vaultDiskSource.getCollections(userId = userId)
} returns MutableStateFlow(listOf(collection))
coEvery {
ciphersService.getCipher(cipherId = cipherId)
} returns updatedCipher.asSuccess()
coEvery {
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
} just runs
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = false,
collectionIds = listOf("mockId-1"),
organizationId = "mock-id",
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
ciphersService.getCipher(cipherId = cipherId)
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow update with no local cipher, but with common collections, should make a request save cipher to disk`() =
runTest {
val userId = MOCK_USER_STATE.activeUserId
val number = 1
val cipherId = "mockId-$number"
val originalCipher = createMockCipher(
number = number,
revisionDate = ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES),
)
val updatedCipher = mockk<SyncResponseJson.Cipher>()
val collection = createMockCollection(number = number)
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery { vaultDiskSource.getCipher(any(), any()) } returns originalCipher
coEvery {
vaultDiskSource.getCollections(userId = userId)
} returns MutableStateFlow(listOf(collection))
coEvery { ciphersService.getCipher(cipherId) } returns updatedCipher.asSuccess()
coEvery {
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
} just runs
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = true,
collectionIds = listOf("mockId-1"),
organizationId = "mock-id",
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
ciphersService.getCipher(cipherId = cipherId)
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
}
}
@Test
fun `syncCipherUpsertFlow update with no local cipher should do nothing`() = runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery { vaultDiskSource.getCipher(userId = userId, cipherId = cipherId) } returns null
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = true,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
}
coVerify(exactly = 0) {
ciphersService.getCipher(cipherId = any())
vaultDiskSource.saveCipher(userId = any(), cipher = any())
}
}
@Test
fun `syncCipherUpsertFlow update with more recent local cipher should do nothing`() = runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
val originalCipher = createMockCipher(
number = number,
revisionDate = ZonedDateTime.now(clock),
)
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
} returns originalCipher
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES),
isUpdate = true,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
}
coVerify(exactly = 0) {
ciphersService.getCipher(cipherId = any())
vaultDiskSource.saveCipher(userId = any(), cipher = any())
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow update failure with 404 code should make a request for a cipher and then delete it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
coEvery {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
} returns createMockCipher(number = number)
val response: HttpException = mockk {
every { code() } returns 404
}
coEvery { ciphersService.getCipher(cipherId = cipherId) } returns response.asFailure()
coEvery { vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId) } just runs
fakeAuthDiskSource.userState = MOCK_USER_STATE
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = true,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
ciphersService.getCipher(cipherId = cipherId)
vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId)
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow create failure with 404 code should make a request for a cipher and do nothing`() =
runTest {
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val response: HttpException = mockk {
every { code() } returns 404
}
coEvery { ciphersService.getCipher(cipherId = cipherId) } returns response.asFailure()
coEvery { vaultDiskSource.getCipher(userId = userId, cipherId = cipherId) } returns null
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = false,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
ciphersService.getCipher(cipherId = cipherId)
}
coVerify(exactly = 0) {
vaultDiskSource.deleteCipher(userId = any(), cipherId = any())
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow valid create success should make a request for a cipher and then store it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
val updatedCipher = mockk<SyncResponseJson.Cipher>()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
} returns null
coEvery {
ciphersService.getCipher(cipherId = cipherId)
} returns updatedCipher.asSuccess()
coEvery {
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
} just runs
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = false,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
ciphersService.getCipher(cipherId = cipherId)
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow valid update success should make a request for a cipher and then store it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-$number"
val originalCipher = mockk<SyncResponseJson.Cipher> {
every { revisionDate } returns ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES)
}
val updatedCipher = mockk<SyncResponseJson.Cipher>()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
} returns originalCipher
coEvery { ciphersService.getCipher(cipherId) } returns updatedCipher.asSuccess()
coEvery {
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
} just runs
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = true,
collectionIds = null,
organizationId = null,
),
)
coVerify(exactly = 1) {
vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
ciphersService.getCipher(cipherId)
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
}
}
@Test
fun `syncFolderDeleteFlow should delete folder from disk and update ciphers`() {
val userId = "mockId-1"