BIT-1128: Expose vault data (#227)

This commit is contained in:
Ramsey Smith 2023-11-09 16:16:06 -07:00 committed by Álison Fernandes
parent a4c34a8704
commit a43a541719
13 changed files with 507 additions and 48 deletions

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.platform.datasource.network.util
import okio.ByteString.Companion.decodeBase64
import java.net.UnknownHostException
import java.nio.charset.Charset
import java.util.Base64
@ -32,3 +33,11 @@ fun String.base64UrlDecodeOrNull(): String? =
.replace("_", "/")
.decodeBase64()
?.string(Charset.defaultCharset())
/**
* Returns true if the throwable represents a no network error.
*/
fun Throwable?.isNoConnectionError(): Boolean {
return this is UnknownHostException ||
this?.cause?.isNoConnectionError() ?: false
}

View file

@ -0,0 +1,48 @@
package com.x8bit.bitwarden.data.platform.repository.model
/**
* A data state that can be used as a template for data in the repository layer.
*/
sealed class DataState<out T> {
/**
* Data that is being wrapped by [DataState].
*/
abstract val data: T?
/**
* Loading state that has no data is available.
*/
data object Loading : DataState<Nothing>() {
override val data: Nothing? get() = null
}
/**
* Loaded state that has data available.
*/
data class Loaded<T>(
override val data: T,
) : DataState<T>()
/**
* Pending state that has data available.
*/
data class Pending<T>(
override val data: T,
) : DataState<T>()
/**
* Error state that may have data available.
*/
data class Error<T>(
val error: Throwable,
override val data: T? = null,
) : DataState<T>()
/**
* No network state that may have data is available.
*/
data class NoNetwork<T>(
override val data: T? = null,
) : DataState<T>()
}

View file

@ -1,12 +1,25 @@
package com.x8bit.bitwarden.data.vault.repository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import kotlinx.coroutines.flow.StateFlow
/**
* Responsible for managing vault data inside the network layer.
*/
interface VaultRepository {
/**
* Flow that represents the current vault data.
*/
val vaultDataStateFlow: StateFlow<DataState<VaultData>>
/**
* Clear the in memory vault data.
*/
fun clearVaultData()
/**
* Attempt to sync the vault data.
*/

View file

@ -3,15 +3,24 @@ package com.x8bit.bitwarden.data.vault.repository
import com.bitwarden.core.InitCryptoRequest
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.network.util.isNoConnectionError
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList
import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
@ -28,8 +37,23 @@ class VaultRepositoryImpl constructor(
private var syncJob: Job = Job().apply { complete() }
private val vaultDataMutableStateFlow =
MutableStateFlow<DataState<VaultData>>(DataState.Loading)
override val vaultDataStateFlow: StateFlow<DataState<VaultData>>
get() = vaultDataMutableStateFlow.asStateFlow()
override fun clearVaultData() {
vaultDataMutableStateFlow.update { DataState.Loading }
}
override fun sync() {
if (!syncJob.isCompleted) return
vaultDataMutableStateFlow.value.data?.let { data ->
vaultDataMutableStateFlow.update {
DataState.Pending(data = data)
}
}
syncJob = scope.launch {
syncService
.sync()
@ -39,20 +63,23 @@ class VaultRepositoryImpl constructor(
userKey = syncResponse.profile?.key,
privateKey = syncResponse.profile?.privateKey,
)
// TODO transform into domain object consumable by VaultViewModel BIT-205.
syncResponse.ciphers?.let { networkCiphers ->
vaultSdkSource.decryptCipherList(
cipherList = networkCiphers.toEncryptedSdkCipherList(),
)
}
syncResponse.folders?.let { networkFolders ->
vaultSdkSource.decryptFolderList(
folderList = networkFolders.toEncryptedSdkFolderList(),
)
}
decryptSyncResponseAndUpdateVaultDataState(
syncResponse = syncResponse,
)
},
onFailure = {
// TODO handle failure BIT-205.
onFailure = { throwable ->
vaultDataMutableStateFlow.update {
if (throwable.isNoConnectionError()) {
DataState.NoNetwork(
data = it.data,
)
} else {
DataState.Error(
error = throwable,
data = it.data,
)
}
}
},
)
}
@ -108,4 +135,34 @@ class VaultRepositoryImpl constructor(
onSuccess = { it.toVaultUnlockResult() },
)
}
private suspend fun decryptSyncResponseAndUpdateVaultDataState(syncResponse: SyncResponseJson) {
val newState = vaultSdkSource
.decryptCipherList(
cipherList = (syncResponse.ciphers ?: emptyList())
.toEncryptedSdkCipherList(),
)
.flatMap { decryptedCipherList ->
vaultSdkSource
.decryptFolderList(
folderList = (syncResponse.folders ?: emptyList())
.toEncryptedSdkFolderList(),
)
.map { decryptedFolderList ->
decryptedCipherList to decryptedFolderList
}
}
.fold(
onSuccess = { (decryptedCipherList, decryptedFolderList) ->
DataState.Loaded(
data = VaultData(
cipherListViewList = decryptedCipherList,
folderViewList = decryptedFolderList,
),
)
},
onFailure = { DataState.Error(error = it) },
)
vaultDataMutableStateFlow.update { newState }
}
}

View file

@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.bitwarden.core.CipherListView
import com.bitwarden.core.FolderView
/**
* Represents decrypted vault data.
*
* @param cipherListViewList List of decrypted ciphers.
* @param folderViewList List of decrypted folders.
*/
data class VaultData(
val cipherListViewList: List<CipherListView>,
val folderViewList: List<FolderView>,
)

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.datasource.network.util
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.net.UnknownHostException
class NetworkUtilsTest {
@Test
@ -40,4 +41,20 @@ class NetworkUtilsTest {
"*.*".base64UrlDecodeOrNull(),
)
}
@Test
fun `isNoConnectionError should return return true for UnknownHostException`() {
assertEquals(
true,
UnknownHostException().isNoConnectionError(),
)
}
@Test
fun `isNoConnectionError should return return false for not UnknownHostException`() {
assertEquals(
false,
IllegalStateException().isNoConnectionError(),
)
}
}

View file

@ -0,0 +1,35 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.core.CipherListView
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import java.time.LocalDateTime
import java.time.ZoneOffset
/**
* Create a mock [CipherListView] with a given [number].
*/
fun createMockCipherListView(number: Int): CipherListView =
CipherListView(
id = "mockId-$number",
organizationId = "mockOrganizationId-$number",
folderId = "mockFolderId-$number",
collectionIds = listOf("mockCollectionId-$number"),
name = "mockName-$number",
type = CipherType.LOGIN,
creationDate = LocalDateTime
.parse("2023-10-27T12:00:00")
.toInstant(ZoneOffset.UTC),
deletedDate = LocalDateTime
.parse("2023-10-27T12:00:00")
.toInstant(ZoneOffset.UTC),
revisionDate = LocalDateTime
.parse("2023-10-27T12:00:00")
.toInstant(ZoneOffset.UTC),
attachments = 1U,
favorite = false,
reprompt = CipherRepromptType.NONE,
edit = false,
viewPassword = false,
subTitle = "",
)

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.core.FolderView
import java.time.LocalDateTime
import java.time.ZoneOffset
/**
* Create a mock [FolderView] with a given [number].
*/
fun createMockFolderView(number: Int): FolderView =
FolderView(
id = "mockId-$number",
name = "mockName-$number",
revisionDate = LocalDateTime
.parse("2023-10-27T12:00:00")
.toInstant(ZoneOffset.UTC),
)

View file

@ -1,4 +1,4 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.core.Attachment
import com.bitwarden.core.Card

View file

@ -1,4 +1,4 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.core.Folder
import java.time.LocalDateTime

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.vault.repository
import app.cash.turbine.test
import com.bitwarden.core.InitCryptoRequest
import com.bitwarden.core.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
@ -8,12 +9,18 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
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.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockSyncResponse
import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkCipher
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkFolder
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkCipher
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFolder
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherListView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import io.mockk.coEvery
import io.mockk.coVerify
@ -21,6 +28,7 @@ import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.net.UnknownHostException
class VaultRepositoryTest {
@ -36,27 +44,228 @@ class VaultRepositoryTest {
)
@Test
fun `sync when syncService Success should update AuthDiskSource with keys`() = runTest {
coEvery { syncService.sync() } returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns mockk()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns mockk()
fakeAuthDiskSource.userState = MOCK_USER_STATE
fun `sync with syncService Success should update AuthDiskSource and vaultDataStateFlow`() =
runTest {
coEvery {
syncService.sync()
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns listOf(createMockCipherListView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns listOf(createMockFolderView(number = 1)).asSuccess()
fakeAuthDiskSource.userState = MOCK_USER_STATE
vaultRepository.sync()
vaultRepository.sync()
fakeAuthDiskSource.assertUserKey(
userId = "mockUserId",
userKey = "mockKey-1",
)
fakeAuthDiskSource.assertPrivateKey(
userId = "mockUserId",
privateKey = "mockPrivateKey-1",
)
}
fakeAuthDiskSource.assertUserKey(
userId = "mockUserId",
userKey = "mockKey-1",
)
fakeAuthDiskSource.assertPrivateKey(
userId = "mockUserId",
privateKey = "mockPrivateKey-1",
)
assertEquals(
DataState.Loaded(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
vaultRepository.vaultDataStateFlow.value,
)
}
@Test
fun `sync with data should update vaultDataStateFlow to Pending before service sync`() =
runTest {
coEvery {
syncService.sync()
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns listOf(createMockCipherListView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns listOf(createMockFolderView(number = 1)).asSuccess()
fakeAuthDiskSource.userState = MOCK_USER_STATE
vaultRepository.vaultDataStateFlow.test {
assertEquals(
DataState.Loading,
awaitItem(),
)
vaultRepository.sync()
assertEquals(
DataState.Loaded(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
awaitItem(),
)
vaultRepository.sync()
assertEquals(
DataState.Pending(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
awaitItem(),
)
assertEquals(
DataState.Loaded(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
awaitItem(),
)
}
}
@Test
fun `sync with decryptCipherList Failure should update vaultDataStateFlow with Error`() =
runTest {
val mockException = IllegalStateException()
coEvery {
syncService.sync()
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns mockException.asFailure()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns listOf(createMockFolderView(number = 1)).asSuccess()
fakeAuthDiskSource.userState = MOCK_USER_STATE
vaultRepository.sync()
assertEquals(
DataState.Error<VaultData>(error = mockException),
vaultRepository.vaultDataStateFlow.value,
)
}
@Test
fun `sync with decryptFolderList Failure should update vaultDataStateFlow with Error`() =
runTest {
val mockException = IllegalStateException()
coEvery {
syncService.sync()
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns listOf(createMockCipherListView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns mockException.asFailure()
fakeAuthDiskSource.userState = MOCK_USER_STATE
vaultRepository.sync()
assertEquals(
DataState.Error<VaultData>(error = mockException),
vaultRepository.vaultDataStateFlow.value,
)
}
@Test
fun `sync with syncService Failure should update vaultDataStateFlow with an Error`() =
runTest {
val mockException = IllegalStateException(
"sad",
)
coEvery {
syncService.sync()
} returns mockException.asFailure()
vaultRepository.sync()
assertEquals(
DataState.Error(
error = mockException,
data = null,
),
vaultRepository.vaultDataStateFlow.value,
)
}
@Test
fun `sync with NoNetwork should update vaultDataStateFlow to NoNetwork`() =
runTest {
coEvery {
syncService.sync()
} returns UnknownHostException().asFailure()
vaultRepository.sync()
assertEquals(
DataState.NoNetwork(
data = null,
),
vaultRepository.vaultDataStateFlow.value,
)
}
@Test
fun `sync with NoNetwork data should update vaultDataStateFlow to NoNetwork with data`() =
runTest {
coEvery {
syncService.sync()
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns listOf(createMockCipherListView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns listOf(createMockFolderView(number = 1)).asSuccess()
fakeAuthDiskSource.userState = MOCK_USER_STATE
vaultRepository.vaultDataStateFlow.test {
assertEquals(
DataState.Loading,
awaitItem(),
)
vaultRepository.sync()
assertEquals(
DataState.Loaded(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
awaitItem(),
)
coEvery {
syncService.sync()
} returns UnknownHostException().asFailure()
vaultRepository.sync()
assertEquals(
DataState.Pending(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
awaitItem(),
)
assertEquals(
DataState.NoNetwork(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
awaitItem(),
)
}
}
@Test
fun `unlockVaultAndSync with initializeCrypto Success should sync and return Success`() =
@ -66,10 +275,10 @@ class VaultRepositoryTest {
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns mockk()
} returns listOf(createMockCipherListView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns mockk()
} returns listOf(createMockFolderView(number = 1)).asSuccess()
fakeAuthDiskSource.storePrivateKey(
userId = "mockUserId",
privateKey = "mockPrivateKey-1",
@ -233,6 +442,45 @@ class VaultRepositoryTest {
result,
)
}
@Test
fun `clearVaultData should update the vaultDataStateFlow to Loading`() =
runTest {
coEvery {
syncService.sync()
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns listOf(createMockCipherListView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns listOf(createMockFolderView(number = 1)).asSuccess()
fakeAuthDiskSource.userState = MOCK_USER_STATE
vaultRepository.vaultDataStateFlow.test {
assertEquals(
DataState.Loading,
awaitItem(),
)
vaultRepository.sync()
assertEquals(
DataState.Loaded(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
awaitItem(),
)
vaultRepository.clearVaultData()
assertEquals(
DataState.Loading,
awaitItem(),
)
}
}
}
private val MOCK_USER_STATE = UserStateJson(

View file

@ -17,15 +17,15 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockLogin
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPasswordHistory
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockSecureNote
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockUri
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkAttachment
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkCard
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkCipher
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkField
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkIdentity
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkLogin
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkPasswordHistory
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkSecureNote
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkUri
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkAttachment
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkCard
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkCipher
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkField
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkIdentity
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkLogin
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkPasswordHistory
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkSecureNote
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkUri
import org.junit.Assert.assertEquals
import org.junit.Test

View file

@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockFolder
import com.x8bit.bitwarden.data.vault.datasource.sdk.createMockSdkFolder
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFolder
import org.junit.Assert.assertEquals
import org.junit.Test