BIT-1547: Setup needed logic to support push notification syncs (#837)

This commit is contained in:
Sean Weiser 2024-01-28 20:48:27 -06:00 committed by Álison Fernandes
parent 7c4092a539
commit 474025b893
15 changed files with 433 additions and 0 deletions

View file

@ -8,6 +8,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
@ -92,4 +93,12 @@ interface CiphersApi {
suspend fun restoreCipher(
@Path("cipherId") cipherId: String,
): Result<Unit>
/**
* Gets a cipher.
*/
@GET("ciphers/{cipherId}")
suspend fun getCipher(
@Path("cipherId") cipherId: String,
): Result<SyncResponseJson.Cipher>
}

View file

@ -4,6 +4,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.FolderJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
@ -19,6 +20,14 @@ interface FoldersApi {
@POST("folders")
suspend fun createFolder(@Body body: FolderJsonRequest): Result<SyncResponseJson.Folder>
/**
* Gets a folder.
*/
@GET("folders/{folderId}")
suspend fun getFolder(
@Path("folderId") folderId: String,
): Result<SyncResponseJson.Folder>
/**
* Updates a folder.
*/

View file

@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
@ -59,4 +60,10 @@ interface SendsApi {
*/
@PUT("sends/{sendId}/remove-password")
suspend fun removeSendPassword(@Path("sendId") sendId: String): Result<SyncResponseJson.Send>
/**
* Gets a send.
*/
@GET("sends/{sendId}")
suspend fun getSend(@Path("sendId") sendId: String): Result<SyncResponseJson.Send>
}

View file

@ -70,4 +70,9 @@ interface CiphersService {
* Attempt to restore a cipher.
*/
suspend fun restoreCipher(cipherId: String): Result<Unit>
/**
* Attempt to retrieve a cipher.
*/
suspend fun getCipher(cipherId: String): Result<SyncResponseJson.Cipher>
}

View file

@ -129,4 +129,9 @@ class CiphersServiceImpl(
override suspend fun restoreCipher(cipherId: String): Result<Unit> =
ciphersApi.restoreCipher(cipherId = cipherId)
override suspend fun getCipher(
cipherId: String,
): Result<SyncResponseJson.Cipher> =
ciphersApi.getCipher(cipherId = cipherId)
}

View file

@ -25,4 +25,9 @@ interface FolderService {
* Attempt to hard delete a folder.
*/
suspend fun deleteFolder(folderId: String): Result<Unit>
/**
* Attempt to retrieve a folder.
*/
suspend fun getFolder(folderId: String): Result<SyncResponseJson.Folder>
}

View file

@ -37,4 +37,9 @@ class FolderServiceImpl constructor(
override suspend fun deleteFolder(folderId: String): Result<Unit> =
foldersApi.deleteFolder(folderId = folderId)
override suspend fun getFolder(
folderId: String,
): Result<SyncResponseJson.Folder> = foldersApi
.getFolder(folderId = folderId)
}

View file

@ -52,4 +52,9 @@ interface SendsService {
suspend fun removeSendPassword(
sendId: String,
): Result<UpdateSendResponseJson>
/**
* Attempt to retrieve a send.
*/
suspend fun getSend(sendId: String): Result<SyncResponseJson.Send>
}

View file

@ -112,4 +112,9 @@ class SendsServiceImpl(
)
?: throw throwable
}
override suspend fun getSend(
sendId: String,
): Result<SyncResponseJson.Send> =
sendsApi.getSend(sendId = sendId)
}

View file

@ -17,7 +17,11 @@ import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.util.isNoConnectionError
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
@ -97,6 +101,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.time.Clock
import java.time.Instant
import java.time.temporal.ChronoUnit
@ -123,6 +128,7 @@ class VaultRepositoryImpl(
private val fileManager: FileManager,
private val vaultLockManager: VaultLockManager,
private val totpCodeManager: TotpCodeManager,
private val pushManager: PushManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
) : VaultRepository,
@ -229,6 +235,21 @@ class VaultRepositoryImpl(
observeVaultDiskSends(activeUserId)
}
.launchIn(unconfinedScope)
pushManager
.syncCipherUpsertFlow
.onEach(::syncCipherIfNecessary)
.launchIn(ioScope)
pushManager
.syncSendUpsertFlow
.onEach(::syncSendIfNecessary)
.launchIn(ioScope)
pushManager
.syncFolderUpsertFlow
.onEach(::syncFolderIfNecessary)
.launchIn(ioScope)
}
override fun clearUnlockedData() {
@ -1209,6 +1230,80 @@ class VaultRepositoryImpl(
)
}
.onEach { mutableSendDataStateFlow.value = it }
//region Push notification helpers
/**
* 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
// TODO Handle other filtering logic including revision date comparison. This will still be
// handled as part of BIT-1547.
val cipherId = syncCipherUpsertData.cipherId
val isUpdate = syncCipherUpsertData.isUpdate
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)
}
},
)
}
/**
* Syncs an individual send contained in [syncSendUpsertData] 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 syncSendIfNecessary(syncSendUpsertData: SyncSendUpsertData) {
val userId = activeUserId ?: return
// TODO Handle other filtering logic including revision date comparison. This will still be
// handled as part of BIT-1547.
val sendId = syncSendUpsertData.sendId
val isUpdate = syncSendUpsertData.isUpdate
sendsService
.getSend(sendId)
.fold(
onSuccess = { vaultDiskSource.saveSend(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.deleteSend(userId = userId, sendId = sendId)
}
},
)
}
/**
* Syncs an individual folder contained in [syncFolderUpsertData] to disk if certain criteria
* are met.
*/
private suspend fun syncFolderIfNecessary(syncFolderUpsertData: SyncFolderUpsertData) {
val userId = activeUserId ?: return
// TODO Handle other filtering logic including revision date comparison. This will still be
// handled as part of BIT-1547.
val folderId = syncFolderUpsertData.folderId
folderService
.getFolder(folderId)
.onSuccess { vaultDiskSource.saveFolder(userId, it) }
}
//endregion Push Notification helpers
}
private fun <T> Throwable.toNetworkOrErrorState(data: T?): DataState<T> =

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.vault.repository.di
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService
@ -43,6 +44,7 @@ object VaultRepositoryModule {
vaultLockManager: VaultLockManager,
dispatcherManager: DispatcherManager,
totpCodeManager: TotpCodeManager,
pushManager: PushManager,
clock: Clock,
): VaultRepository = VaultRepositoryImpl(
syncService = syncService,
@ -57,6 +59,7 @@ object VaultRepositoryModule {
vaultLockManager = vaultLockManager,
dispatcherManager = dispatcherManager,
totpCodeManager = totpCodeManager,
pushManager = pushManager,
clock = clock,
)
}

View file

@ -202,6 +202,16 @@ class CiphersServiceTest : BaseServiceTest() {
val result = ciphersService.restoreCipher(cipherId = cipherId)
assertEquals(Unit, result.getOrThrow())
}
@Test
fun `getCipher should return the correct response`() = runTest {
server.enqueue(MockResponse().setBody(CREATE_UPDATE_CIPHER_SUCCESS_JSON))
val result = ciphersService.getCipher(cipherId = "mockId-1")
assertEquals(
createMockCipher(number = 1),
result.getOrThrow(),
)
}
}
private fun setupMockUri(

View file

@ -71,6 +71,16 @@ class FoldersServiceTest : BaseServiceTest() {
val result = folderService.deleteFolder(DEFAULT_ID)
assertEquals(Unit, result.getOrThrow())
}
@Test
fun `getFolder should return the correct response`() = runTest {
server.enqueue(MockResponse().setBody(CREATE_UPDATE_FOLDER_SUCCESS_JSON))
val result = folderService.getFolder("FolderId")
assertEquals(
DEFAULT_FOLDER,
result.getOrThrow(),
)
}
}
private const val DEFAULT_ID = "FolderId"

View file

@ -182,6 +182,14 @@ class SendsServiceTest : BaseServiceTest() {
)
}
@Test
fun `getSend should return the correct response`() = runTest {
val response = createMockSend(number = 1)
server.enqueue(MockResponse().setBody(CREATE_UPDATE_SEND_SUCCESS_JSON))
val result = sendsService.getSend("mockId-1")
assertEquals(response, result.getOrThrow())
}
private fun setupMockUri(
url: String,
queryParams: Map<String, String>,

View file

@ -22,7 +22,11 @@ import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.util.asFailure
@ -117,6 +121,7 @@ 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.time.Clock
import java.time.Instant
@ -157,6 +162,15 @@ class VaultRepositoryTest {
every { lockVaultForCurrentUser() } just runs
}
private val mutableSyncCipherUpsertFlow = bufferedMutableSharedFlow<SyncCipherUpsertData>()
private val mutableSyncSendUpsertFlow = bufferedMutableSharedFlow<SyncSendUpsertData>()
private val mutableSyncFolderUpsertFlow = bufferedMutableSharedFlow<SyncFolderUpsertData>()
private val pushManager: PushManager = mockk {
every { syncCipherUpsertFlow } returns mutableSyncCipherUpsertFlow
every { syncSendUpsertFlow } returns mutableSyncSendUpsertFlow
every { syncFolderUpsertFlow } returns mutableSyncFolderUpsertFlow
}
private val vaultRepository = VaultRepositoryImpl(
syncService = syncService,
sendsService = sendsService,
@ -169,6 +183,7 @@ class VaultRepositoryTest {
vaultLockManager = vaultLockManager,
dispatcherManager = dispatcherManager,
totpCodeManager = totpCodeManager,
pushManager = pushManager,
fileManager = fileManager,
clock = clock,
)
@ -3654,6 +3669,243 @@ class VaultRepositoryTest {
}
}
@Test
fun `syncCipherUpsertFlow success should make a request for a cipher and then store it`() =
runTest {
val cipherId = "mockId-1"
val cipher: SyncResponseJson.Cipher = mockk()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
ciphersService.getCipher(cipherId)
} returns cipher.asSuccess()
coEvery {
vaultDiskSource.saveCipher(any(), any())
} just runs
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(),
isUpdate = false,
),
)
coVerify(exactly = 1) {
ciphersService.getCipher(cipherId)
vaultDiskSource.saveCipher(
userId = MOCK_USER_STATE.activeUserId,
cipher = cipher,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow update failure with 404 code should make a request for a cipher and then delete it`() =
runTest {
every {
pushManager.syncCipherUpsertFlow
}
val cipherId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val response: HttpException = mockk {
every { code() } returns 404
}
coEvery {
ciphersService.getCipher(cipherId)
} returns response.asFailure()
coEvery {
vaultDiskSource.deleteCipher(any(), any())
} just runs
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(),
isUpdate = true,
),
)
coVerify(exactly = 1) {
ciphersService.getCipher(cipherId)
vaultDiskSource.deleteCipher(
userId = MOCK_USER_STATE.activeUserId,
cipherId = cipherId,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `syncCipherUpsertFlow create failure with 404 code should make a request for a cipher and do nothing`() =
runTest {
val cipherId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val response: HttpException = mockk {
every { code() } returns 404
}
coEvery {
ciphersService.getCipher(cipherId)
} returns response.asFailure()
mutableSyncCipherUpsertFlow.tryEmit(
SyncCipherUpsertData(
cipherId = cipherId,
revisionDate = ZonedDateTime.now(),
isUpdate = false,
),
)
coVerify(exactly = 1) {
ciphersService.getCipher(cipherId)
}
coVerify(exactly = 0) {
vaultDiskSource.deleteCipher(
userId = MOCK_USER_STATE.activeUserId,
cipherId = cipherId,
)
}
}
@Test
fun `syncSendUpsertFlow success should make a request for a send and then store it`() =
runTest {
val sendId = "mockId-1"
val send: SyncResponseJson.Send = mockk()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
sendsService.getSend(sendId)
} returns send.asSuccess()
coEvery {
vaultDiskSource.saveSend(any(), any())
} just runs
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(),
isUpdate = false,
),
)
coVerify(exactly = 1) {
sendsService.getSend(sendId)
vaultDiskSource.saveSend(
userId = MOCK_USER_STATE.activeUserId,
send = send,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `syncSendUpsertFlow update failure with 404 code should make a request for a send and then delete it`() =
runTest {
val sendId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val response: HttpException = mockk {
every { code() } returns 404
}
coEvery {
sendsService.getSend(sendId)
} returns response.asFailure()
coEvery {
vaultDiskSource.deleteSend(any(), any())
} just runs
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(),
isUpdate = true,
),
)
coVerify(exactly = 1) {
sendsService.getSend(sendId)
vaultDiskSource.deleteSend(
userId = MOCK_USER_STATE.activeUserId,
sendId = sendId,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `syncSendUpsertFlow create failure with 404 code should make a request for a send and do nothing`() =
runTest {
val sendId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val response: HttpException = mockk {
every { code() } returns 404
}
coEvery {
sendsService.getSend(sendId)
} returns response.asFailure()
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(),
isUpdate = false,
),
)
coVerify(exactly = 1) {
sendsService.getSend(sendId)
}
coVerify(exactly = 0) {
vaultDiskSource.deleteSend(
userId = MOCK_USER_STATE.activeUserId,
sendId = sendId,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `mutableSyncFolderUpsertFlow success should make a request for a folder and then store it`() =
runTest {
val folderId = "mockId-1"
val folder: SyncResponseJson.Folder = mockk()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
folderService.getFolder(folderId)
} returns folder.asSuccess()
coEvery {
vaultDiskSource.saveFolder(any(), any())
} just runs
mutableSyncFolderUpsertFlow.tryEmit(
SyncFolderUpsertData(
folderId = folderId,
revisionDate = ZonedDateTime.now(),
isUpdate = false,
),
)
coVerify(exactly = 1) {
folderService.getFolder(folderId)
vaultDiskSource.saveFolder(
userId = MOCK_USER_STATE.activeUserId,
folder = folder,
)
}
}
//region Helper functions
/**