BIT-205: Populate vault with login items (#246)

This commit is contained in:
Ramsey Smith 2023-11-15 14:15:04 -07:00 committed by Álison Fernandes
parent 3e6ce662d8
commit fd10de9456
13 changed files with 688 additions and 90 deletions

View file

@ -8,31 +8,24 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
/**
* Used to serialize and deserialize [LocalDateTime].
*/
class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
private val localDateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SS'Z'")
private val localDateTimeFormatterNanoSeconds =
private val dateTimeFormatterDeserialization = DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss.[SSSSSSS][SSSSSS][SSSSS][SSSS][SSS][SS][S]'Z'")
private val dateTimeFormatterSerialization =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSS'Z'")
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor(serialName = "LocalDateTime", kind = PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): LocalDateTime =
decoder.decodeString().let { dateString ->
try {
LocalDateTime
.parse(dateString, localDateTimeFormatter)
} catch (exception: DateTimeParseException) {
LocalDateTime
.parse(dateString, localDateTimeFormatterNanoSeconds)
}
LocalDateTime.parse(dateString, dateTimeFormatterDeserialization)
}
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(localDateTimeFormatter.format(value))
encoder.encodeString(dateTimeFormatterSerialization.format(value))
}
}

View file

@ -27,7 +27,12 @@ interface VaultSdkSource {
/**
* Decrypts a list of [Cipher]s returning a list of [CipherListView] wrapped in a [Result].
*/
suspend fun decryptCipherList(cipherList: List<Cipher>): Result<List<CipherListView>>
suspend fun decryptCipherListCollection(cipherList: List<Cipher>): Result<List<CipherListView>>
/**
* Decrypts a list of [Cipher]s returning a list of [CipherView] wrapped in a [Result].
*/
suspend fun decryptCipherList(cipherList: List<Cipher>): Result<List<CipherView>>
/**
* Decrypts a [Folder] returning a [FolderView] wrapped in a [Result].

View file

@ -35,9 +35,14 @@ class VaultSdkSourceImpl(
override suspend fun decryptCipher(cipher: Cipher): Result<CipherView> =
runCatching { clientVault.ciphers().decrypt(cipher) }
override suspend fun decryptCipherList(cipherList: List<Cipher>): Result<List<CipherListView>> =
override suspend fun decryptCipherListCollection(
cipherList: List<Cipher>,
): Result<List<CipherListView>> =
runCatching { clientVault.ciphers().decryptList(cipherList) }
override suspend fun decryptCipherList(cipherList: List<Cipher>): Result<List<CipherView>> =
runCatching { cipherList.map { clientVault.ciphers().decrypt(it) } }
override suspend fun decryptFolder(folder: Folder): Result<FolderView> =
runCatching { clientVault.folders().decrypt(folder) }

View file

@ -20,6 +20,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -37,6 +41,8 @@ class VaultRepositoryImpl constructor(
private var syncJob: Job = Job().apply { complete() }
private var willSyncAfterUnlock = false
private val vaultDataMutableStateFlow =
MutableStateFlow<DataState<VaultData>>(DataState.Loading)
@ -48,7 +54,7 @@ class VaultRepositoryImpl constructor(
}
override fun sync() {
if (!syncJob.isCompleted) return
if (!syncJob.isCompleted || willSyncAfterUnlock) return
vaultDataMutableStateFlow.value.data?.let { data ->
vaultDataMutableStateFlow.update {
DataState.Pending(data = data)
@ -86,10 +92,16 @@ class VaultRepositoryImpl constructor(
}
override suspend fun unlockVaultAndSync(masterPassword: String): VaultUnlockResult {
return initializeCrypto(masterPassword = masterPassword)
.also { vaultUnlockedResult ->
if (vaultUnlockedResult is VaultUnlockResult.Success) sync()
return flow {
willSyncAfterUnlock = true
emit(initializeCrypto(masterPassword = masterPassword))
}
.onEach {
willSyncAfterUnlock = false
if (it is VaultUnlockResult.Success) sync()
}
.onCompletion { willSyncAfterUnlock = false }
.first()
}
private fun storeUserKeyAndPrivateKey(
@ -156,7 +168,7 @@ class VaultRepositoryImpl constructor(
onSuccess = { (decryptedCipherList, decryptedFolderList) ->
DataState.Loaded(
data = VaultData(
cipherListViewList = decryptedCipherList,
cipherViewList = decryptedCipherList,
folderViewList = decryptedFolderList,
),
)

View file

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

View file

@ -7,12 +7,16 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.base.util.hexToColor
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
@ -32,8 +36,8 @@ private const val KEY_STATE = "state"
@HiltViewModel
class VaultViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
vaultRepository: VaultRepository,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
// TODO retrieve this from the data layer BIT-205
initialState = savedStateHandle[KEY_STATE] ?: VaultState(
initials = activeAccountSummary.initials,
avatarColorString = activeAccountSummary.avatarColorHex,
@ -46,30 +50,18 @@ class VaultViewModel @Inject constructor(
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
vaultRepository
.vaultDataStateFlow
.onEach { sendAction(VaultAction.Internal.VaultDataReceive(vaultData = it)) }
.launchIn(viewModelScope)
// TODO remove this block once vault unlocked is implemented in BIT-1082
viewModelScope.launch {
// TODO will need to load actual vault items BIT-205
@Suppress("MagicNumber")
delay(2000)
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = VaultState.ViewState.Content(
loginItemsCount = 0,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = emptyList(),
folderItems = listOf(
VaultState.ViewState.FolderItem(
id = null,
name = R.string.folder_none.asText(),
itemCount = 0,
),
),
// TODO Take into account the max threshold of no folder as well as the
// case where it is empty, in which case, no folder is a folder. BIT-205
noFolderItems = emptyList(),
trashItemsCount = 0,
delay(5000)
if (vaultRepository.vaultDataStateFlow.value == DataState.Loading) {
sendAction(
VaultAction.Internal.VaultDataReceive(
DataState.Error(error = IllegalStateException()),
),
)
}
@ -78,17 +70,18 @@ class VaultViewModel @Inject constructor(
override fun handleAction(action: VaultAction) {
when (action) {
VaultAction.AddItemClick -> handleAddItemClick()
VaultAction.CardGroupClick -> handleCardClick()
is VaultAction.AddItemClick -> handleAddItemClick()
is VaultAction.CardGroupClick -> handleCardClick()
is VaultAction.FolderClick -> handleFolderItemClick(action)
VaultAction.IdentityGroupClick -> handleIdentityClick()
VaultAction.LoginGroupClick -> handleLoginClick()
VaultAction.SearchIconClick -> handleSearchIconClick()
is VaultAction.IdentityGroupClick -> handleIdentityClick()
is VaultAction.LoginGroupClick -> handleLoginClick()
is VaultAction.SearchIconClick -> handleSearchIconClick()
is VaultAction.AccountSwitchClick -> handleAccountSwitchClick(action)
VaultAction.AddAccountClick -> handleAddAccountClick()
VaultAction.SecureNoteGroupClick -> handleSecureNoteClick()
VaultAction.TrashClick -> handleTrashClick()
is VaultAction.AddAccountClick -> handleAddAccountClick()
is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick()
is VaultAction.TrashClick -> handleTrashClick()
is VaultAction.VaultItemClick -> handleVaultItemClick(action)
is VaultAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
}
}
@ -150,6 +143,41 @@ class VaultViewModel @Inject constructor(
private fun handleVaultItemClick(action: VaultAction.VaultItemClick) {
sendEvent(VaultEvent.NavigateToVaultItem(action.vaultItem.id))
}
private fun handleVaultDataReceive(action: VaultAction.Internal.VaultDataReceive) {
when (val vaultData = action.vaultData) {
is DataState.Error -> vaultErrorReceive(vaultData = vaultData)
is DataState.Loaded -> vaultLoadedReceive(vaultData = vaultData)
is DataState.Loading -> vaultLoadingReceive()
is DataState.NoNetwork -> vaultNoNetworkReceive(vaultData = vaultData)
is DataState.Pending -> vaultPendingReceive(vaultData = vaultData)
}
}
private fun vaultErrorReceive(vaultData: DataState.Error<VaultData>) {
// TODO update state to error state BIT-1157
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) }
sendEvent(VaultEvent.ShowToast(message = "Vault error state not yet implemented"))
}
private fun vaultLoadedReceive(vaultData: DataState.Loaded<VaultData>) {
mutableStateFlow.update { it.copy(viewState = vaultData.data.toViewState()) }
}
private fun vaultLoadingReceive() {
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.Loading) }
}
private fun vaultNoNetworkReceive(vaultData: DataState.NoNetwork<VaultData>) {
// TODO update state to no network state BIT-1158
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) }
sendEvent(VaultEvent.ShowToast(message = "Vault no network state not yet implemented"))
}
private fun vaultPendingReceive(vaultData: DataState.Pending<VaultData>) {
// TODO update state to refresh state BIT-505
mutableStateFlow.update { it.copy(viewState = vaultData.data.toViewState()) }
sendEvent(VaultEvent.ShowToast(message = "Refreshing"))
}
//endregion VaultAction Handlers
}
@ -499,4 +527,17 @@ sealed class VaultAction {
* User clicked the trash button.
*/
data object TrashClick : VaultAction()
/**
* Models actions that the [VaultViewModel] itself might send.
*/
sealed class Internal : VaultAction() {
/**
* Indicates a vault data was received.
*/
data class VaultDataReceive(
val vaultData: DataState<VaultData>,
) : Internal()
}
}

View file

@ -0,0 +1,70 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.util
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
/**
* Transforms a [CipherView] into a [VaultState.ViewState.VaultItem].
*/
@Suppress("MagicNumber")
private fun CipherView.toVaultItem(): VaultState.ViewState.VaultItem =
when (type) {
CipherType.LOGIN -> VaultState.ViewState.VaultItem.Login(
id = id.toString(),
name = name.asText(),
username = login?.username?.asText(),
)
CipherType.SECURE_NOTE -> VaultState.ViewState.VaultItem.SecureNote(
id = id.toString(),
name = name.asText(),
)
CipherType.CARD -> VaultState.ViewState.VaultItem.Card(
id = id.toString(),
name = name.asText(),
brand = card?.brand?.asText(),
lastFourDigits = card?.number
?.takeLast(4)
?.asText(),
)
CipherType.IDENTITY -> VaultState.ViewState.VaultItem.Identity(
id = id.toString(),
name = name.asText(),
firstName = identity?.firstName?.asText(),
)
}
/**
* Transforms [VaultData] into [VaultState.ViewState].
*/
fun VaultData.toViewState(): VaultState.ViewState =
if (cipherViewList.isEmpty() && folderViewList.isEmpty()) {
VaultState.ViewState.NoItems
} else {
VaultState.ViewState.Content(
loginItemsCount = cipherViewList.count { it.type == CipherType.LOGIN },
cardItemsCount = cipherViewList.count { it.type == CipherType.CARD },
identityItemsCount = cipherViewList.count { it.type == CipherType.IDENTITY },
secureNoteItemsCount = cipherViewList.count { it.type == CipherType.SECURE_NOTE },
favoriteItems = cipherViewList
.filter { it.favorite }
.map { it.toVaultItem() },
folderItems = folderViewList.map { folderView ->
VaultState.ViewState.FolderItem(
id = folderView.id,
name = folderView.name.asText(),
itemCount = cipherViewList.count { folderView.id == it.folderId },
)
},
noFolderItems = cipherViewList
.filter { it.folderId.isNullOrBlank() }
.map { it.toVaultItem() },
// TODO need to populate trash item count in BIT-969
trashItemsCount = 0,
)
}

View file

@ -45,18 +45,18 @@ class LocalDateTimeSerializerTest {
LocalDateTimeData(
dataAsLocalDateTime = LocalDateTime.of(
2023,
10,
6,
17,
22,
28,
446666700,
8,
1,
16,
13,
3,
502391000,
),
),
json.decodeFromString<LocalDateTimeData>(
"""
{
"dataAsLocalDateTime": "2023-10-06T17:22:28.4466667Z"
"dataAsLocalDateTime": "2023-08-01T16:13:03.502391Z"
}
""",
),
@ -69,7 +69,7 @@ class LocalDateTimeSerializerTest {
json.parseToJsonElement(
"""
{
"dataAsLocalDateTime": "2023-10-06T17:22:28.44Z"
"dataAsLocalDateTime": "2023-10-06T17:22:28.4400000Z"
}
""",
),

View file

@ -121,25 +121,49 @@ class VaultSdkSourceTest {
}
}
@Test
fun `Cipher decryptListCollection should call SDK and return a Result with correct data`() =
runBlocking {
val mockCiphers = mockk<List<Cipher>>()
val expectedResult = mockk<List<CipherListView>>()
coEvery {
clientVault.ciphers().decryptList(
ciphers = mockCiphers,
)
} returns expectedResult
val result = vaultSdkSource.decryptCipherListCollection(
cipherList = mockCiphers,
)
assertEquals(
expectedResult.asSuccess(),
result,
)
coVerify {
clientVault.ciphers().decryptList(
ciphers = mockCiphers,
)
}
}
@Test
fun `Cipher decryptList should call SDK and return a Result with correct data`() = runBlocking {
val mockCiphers = mockk<List<Cipher>>()
val expectedResult = mockk<List<CipherListView>>()
val mockCiphers = mockk<Cipher>()
val expectedResult = mockk<CipherView>()
coEvery {
clientVault.ciphers().decryptList(
ciphers = mockCiphers,
clientVault.ciphers().decrypt(
cipher = mockCiphers,
)
} returns expectedResult
val result = vaultSdkSource.decryptCipherList(
cipherList = mockCiphers,
cipherList = listOf(mockCiphers),
)
assertEquals(
expectedResult.asSuccess(),
listOf(expectedResult).asSuccess(),
result,
)
coVerify {
clientVault.ciphers().decryptList(
ciphers = mockCiphers,
clientVault.ciphers().decrypt(
cipher = mockCiphers,
)
}
}

View file

@ -0,0 +1,160 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.core.AttachmentView
import com.bitwarden.core.CardView
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.FieldType
import com.bitwarden.core.FieldView
import com.bitwarden.core.IdentityView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
import com.bitwarden.core.PasswordHistoryView
import com.bitwarden.core.SecureNoteType
import com.bitwarden.core.SecureNoteView
import com.bitwarden.core.UriMatchType
import java.time.LocalDateTime
import java.time.ZoneOffset
/**
* Create a mock [CipherView] with a given [number].
*/
fun createMockCipherView(number: Int): CipherView =
CipherView(
id = "mockId-$number",
organizationId = "mockOrganizationId-$number",
folderId = "mockId-$number",
collectionIds = listOf("mockCollectionId-$number"),
key = "mockKey-$number",
name = "mockName-$number",
notes = "mockNotes-$number",
type = CipherType.LOGIN,
login = createMockLoginView(number = number),
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 = listOf(createMockAttachmentView(number = number)),
card = createMockCardView(number = number),
fields = listOf(createMockFieldView(number = number)),
identity = createMockIdentityView(number = number),
favorite = false,
passwordHistory = listOf(createMockPasswordHistoryView(number = number)),
reprompt = CipherRepromptType.NONE,
secureNote = createMockSecureNoteView(),
edit = false,
organizationUseTotp = false,
viewPassword = false,
localData = null,
)
/**
* Create a mock [LoginView] with a given [number].
*/
fun createMockLoginView(number: Int): LoginView =
LoginView(
username = "mockUsername-$number",
password = "mockPassword-$number",
passwordRevisionDate = LocalDateTime
.parse("2023-10-27T12:00:00")
.toInstant(ZoneOffset.UTC),
autofillOnPageLoad = false,
uris = listOf(createMockUriView(number = number)),
totp = "mockTotp-$number",
)
/**
* Create a mock [LoginUriView] with a given [number].
*/
fun createMockUriView(number: Int): LoginUriView =
LoginUriView(
uri = "mockUri-$number",
match = UriMatchType.HOST,
)
/**
* Create a mock [AttachmentView] with a given [number].
*/
fun createMockAttachmentView(number: Int): AttachmentView =
AttachmentView(
fileName = "mockFileName-$number",
size = "1",
sizeName = "mockSizeName-$number",
id = "mockId-$number",
url = "mockUrl-$number",
key = "mockKey-$number",
)
/**
* Create a mock [CardView] with a given [number].
*/
fun createMockCardView(number: Int): CardView =
CardView(
number = "mockNumber-$number",
expMonth = "mockExpMonth-$number",
code = "mockCode-$number",
expYear = "mockExpirationYear-$number",
cardholderName = "mockCardholderName-$number",
brand = "mockBrand-$number",
)
/**
* Create a mock [FieldView] with a given [number].
*/
fun createMockFieldView(number: Int): FieldView =
FieldView(
linkedId = 100U,
name = "mockName-$number",
type = FieldType.HIDDEN,
value = "mockValue-$number",
)
/**
* Create a mock [IdentityView] with a given [number].
*/
fun createMockIdentityView(number: Int): IdentityView =
IdentityView(
firstName = "mockFirstName-$number",
middleName = "mockMiddleName-$number",
lastName = "mockLastName-$number",
passportNumber = "mockPassportNumber-$number",
country = "mockCountry-$number",
address1 = "mockAddress1-$number",
address2 = "mockAddress2-$number",
address3 = "mockAddress3-$number",
city = "mockCity-$number",
postalCode = "mockPostalCode-$number",
title = "mockTitle-$number",
ssn = "mockSsn-$number",
phone = "mockPhone-$number",
company = "mockCompany-$number",
licenseNumber = "mockLicenseNumber-$number",
state = "mockState-$number",
email = "mockEmail-$number",
username = "mockUsername-$number",
)
/**
* Create a mock [PasswordHistoryView] with a given [number].
*/
fun createMockPasswordHistoryView(number: Int): PasswordHistoryView =
PasswordHistoryView(
password = "mockPassword-$number",
lastUsedDate = LocalDateTime
.parse("2023-10-27T12:00:00")
.toInstant(ZoneOffset.UTC),
)
/**
* Create a mock [SecureNoteView] with a given [number].
*/
fun createMockSecureNoteView(): SecureNoteView =
SecureNoteView(
type = SecureNoteType.GENERIC,
)

View file

@ -18,13 +18,18 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResul
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
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.createMockCipherView
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
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -51,7 +56,7 @@ class VaultRepositoryTest {
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns listOf(createMockCipherListView(number = 1)).asSuccess()
} returns listOf(createMockCipherView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns listOf(createMockFolderView(number = 1)).asSuccess()
@ -70,7 +75,7 @@ class VaultRepositoryTest {
assertEquals(
DataState.Loaded(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
@ -86,7 +91,7 @@ class VaultRepositoryTest {
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns listOf(createMockCipherListView(number = 1)).asSuccess()
} returns listOf(createMockCipherView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns listOf(createMockFolderView(number = 1)).asSuccess()
@ -101,7 +106,7 @@ class VaultRepositoryTest {
assertEquals(
DataState.Loaded(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
@ -111,7 +116,7 @@ class VaultRepositoryTest {
assertEquals(
DataState.Pending(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
@ -120,7 +125,7 @@ class VaultRepositoryTest {
assertEquals(
DataState.Loaded(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
@ -161,7 +166,7 @@ class VaultRepositoryTest {
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns listOf(createMockCipherListView(number = 1)).asSuccess()
} returns listOf(createMockCipherView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns mockException.asFailure()
@ -221,7 +226,7 @@ class VaultRepositoryTest {
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns listOf(createMockCipherListView(number = 1)).asSuccess()
} returns listOf(createMockCipherView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns listOf(createMockFolderView(number = 1)).asSuccess()
@ -236,7 +241,7 @@ class VaultRepositoryTest {
assertEquals(
DataState.Loaded(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
@ -249,7 +254,7 @@ class VaultRepositoryTest {
assertEquals(
DataState.Pending(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
@ -258,7 +263,7 @@ class VaultRepositoryTest {
assertEquals(
DataState.NoNetwork(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
@ -275,7 +280,7 @@ class VaultRepositoryTest {
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns listOf(createMockCipherListView(number = 1)).asSuccess()
} returns listOf(createMockCipherView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns listOf(createMockFolderView(number = 1)).asSuccess()
@ -310,6 +315,102 @@ class VaultRepositoryTest {
coVerify { syncService.sync() }
}
@Test
fun `sync should be able to be called after unlockVaultAndSync is canceled`() = runTest {
coEvery {
syncService.sync()
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns listOf(createMockCipherView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns listOf(createMockFolderView(number = 1)).asSuccess()
fakeAuthDiskSource.storePrivateKey(
userId = "mockUserId",
privateKey = "mockPrivateKey-1",
)
fakeAuthDiskSource.storeUserKey(
userId = "mockUserId",
userKey = "mockKey-1",
)
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultSdkSource.initializeCrypto(
request = InitCryptoRequest(
kdfParams = Kdf.Pbkdf2(iterations = DEFAULT_PBKDF2_ITERATIONS.toUInt()),
email = "email",
password = "mockPassword-1",
userKey = "mockKey-1",
privateKey = "mockPrivateKey-1",
organizationKeys = mapOf(),
),
)
} coAnswers {
delay(Long.MAX_VALUE)
Result.success(InitializeCryptoResult.Success)
}
val scope = CoroutineScope(Dispatchers.Unconfined)
scope.launch {
vaultRepository.unlockVaultAndSync(masterPassword = "mockPassword-1")
}
coVerify(exactly = 0) { syncService.sync() }
scope.cancel()
vaultRepository.sync()
coVerify(exactly = 1) { syncService.sync() }
}
@Test
fun `sync should not be able to be called while unlockVaultAndSync is called`() = runTest {
coEvery {
syncService.sync()
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns listOf(createMockCipherView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns listOf(createMockFolderView(number = 1)).asSuccess()
fakeAuthDiskSource.storePrivateKey(
userId = "mockUserId",
privateKey = "mockPrivateKey-1",
)
fakeAuthDiskSource.storeUserKey(
userId = "mockUserId",
userKey = "mockKey-1",
)
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultSdkSource.initializeCrypto(
request = InitCryptoRequest(
kdfParams = Kdf.Pbkdf2(iterations = DEFAULT_PBKDF2_ITERATIONS.toUInt()),
email = "email",
password = "mockPassword-1",
userKey = "mockKey-1",
privateKey = "mockPrivateKey-1",
organizationKeys = mapOf(),
),
)
} coAnswers {
delay(Long.MAX_VALUE)
Result.success(InitializeCryptoResult.Success)
}
val scope = CoroutineScope(Dispatchers.Unconfined)
scope.launch {
vaultRepository.unlockVaultAndSync(masterPassword = "mockPassword-1")
}
// We call sync here but the call to the SyncService should be blocked
// by the active call to unlockVaultAndSync
vaultRepository.sync()
scope.cancel()
coVerify(exactly = 0) { syncService.sync() }
}
@Test
fun `unlockVaultAndSync with initializeCrypto failure should return GenericError`() =
runTest {
@ -451,7 +552,7 @@ class VaultRepositoryTest {
} returns Result.success(createMockSyncResponse(number = 1))
coEvery {
vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1)))
} returns listOf(createMockCipherListView(number = 1)).asSuccess()
} returns listOf(createMockCipherView(number = 1)).asSuccess()
coEvery {
vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1)))
} returns listOf(createMockFolderView(number = 1)).asSuccess()
@ -466,7 +567,7 @@ class VaultRepositoryTest {
assertEquals(
DataState.Loaded(
data = VaultData(
cipherListViewList = listOf(createMockCipherListView(number = 1)),
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),

View file

@ -3,15 +3,31 @@ package com.x8bit.bitwarden.ui.vault.feature.vault
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class VaultViewModelTest : BaseViewModelTest() {
private val mutableVaultDataStateFlow =
MutableStateFlow<DataState<VaultData>>(DataState.Loading)
private val vaultRepository: VaultRepository =
mockk {
every { vaultDataStateFlow } returns mutableVaultDataStateFlow
every { sync() } returns Unit
}
@Test
fun `initial state should be correct when not set`() {
val viewModel = createViewModel()
@ -82,6 +98,113 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `vaultDataStateFlow Loaded with items should update state to Content`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
)
val viewModel = createViewModel()
assertEquals(
createMockVaultState(
viewState = VaultState.ViewState.Content(
loginItemsCount = 1,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = listOf(),
folderItems = listOf(
VaultState.ViewState.FolderItem(
id = "mockId-1",
name = "mockName-1".asText(),
itemCount = 1,
),
),
noFolderItems = listOf(),
trashItemsCount = 0,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Loaded with empty items should update state to NoItems`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Loaded(
data = VaultData(
cipherViewList = emptyList(),
folderViewList = emptyList(),
),
),
)
val viewModel = createViewModel()
assertEquals(
createMockVaultState(viewState = VaultState.ViewState.NoItems),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Loading should update state to Loading`() = runTest {
mutableVaultDataStateFlow.tryEmit(value = DataState.Loading)
val viewModel = createViewModel()
assertEquals(
createMockVaultState(viewState = VaultState.ViewState.Loading),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Error should show toast and update state to NoItems`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Error(
error = IllegalStateException(),
),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
assertEquals(
VaultEvent.ShowToast("Vault error state not yet implemented"),
awaitItem(),
)
}
assertEquals(
createMockVaultState(viewState = VaultState.ViewState.NoItems),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow NoNetwork should show toast and update state to NoItems`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.NoNetwork(),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
assertEquals(
VaultEvent.ShowToast("Vault no network state not yet implemented"),
awaitItem(),
)
}
assertEquals(
createMockVaultState(viewState = VaultState.ViewState.NoItems),
viewModel.stateFlow.value,
)
}
@Test
fun `AddItemClick should emit NavigateToAddItemScreen`() = runTest {
val viewModel = createViewModel()
@ -175,12 +298,19 @@ class VaultViewModelTest : BaseViewModelTest() {
state: VaultState? = DEFAULT_STATE,
): VaultViewModel = VaultViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", state) },
vaultRepository = vaultRepository,
)
}
private val DEFAULT_STATE: VaultState = VaultState(
avatarColorString = "FF0000FF",
initials = "BW",
accountSummaries = emptyList(),
viewState = VaultState.ViewState.Loading,
)
private const val DEFAULT_COLOR_STRING: String = "FF0000FF"
private const val DEFAULE_INITIALS: String = "BW"
private val DEFAULT_STATE: VaultState =
createMockVaultState(viewState = VaultState.ViewState.Loading)
private fun createMockVaultState(viewState: VaultState.ViewState): VaultState =
VaultState(
avatarColorString = DEFAULT_COLOR_STRING,
initials = DEFAULE_INITIALS,
accountSummaries = emptyList(),
viewState = viewState,
)

View file

@ -0,0 +1,57 @@
package com.x8bit.bitwarden.ui.vault.feature.vault.util
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class VaultDataExtensionsTest {
@Test
fun `toViewState should transform full VaultData into ViewState Content`() {
val vaultData = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
)
val actual = vaultData.toViewState()
assertEquals(
VaultState.ViewState.Content(
loginItemsCount = 1,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = listOf(),
folderItems = listOf(
VaultState.ViewState.FolderItem(
id = "mockId-1",
name = "mockName-1".asText(),
itemCount = 1,
),
),
noFolderItems = listOf(),
trashItemsCount = 0,
),
actual,
)
}
@Test
fun `toViewState should transform empty VaultData into ViewState NoItems`() {
val vaultData = VaultData(
cipherViewList = emptyList(),
folderViewList = emptyList(),
)
val actual = vaultData.toViewState()
assertEquals(
VaultState.ViewState.NoItems,
actual,
)
}
}