diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializer.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializer.kt index a0cb57568..a9c991182 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializer.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializer.kt @@ -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)) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt index b359acfe5..ab94758b0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt @@ -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]. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt index 9d990b2d0..af23b61c1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt @@ -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) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 5bf22129c..d58253cf6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -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, ), ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/VaultData.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/VaultData.kt index e92504a7b..2d4f9d195 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/VaultData.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/VaultData.kt @@ -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>, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 5c79c293b..95c3c17bb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -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() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt new file mode 100644 index 000000000..66316c1d2 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -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, + ) + } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializerTest.kt index 9c11150f2..58ccb2540 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializerTest.kt @@ -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" } """, ), diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt index bb1cdfa1a..1c5f026d7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt @@ -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, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt new file mode 100644 index 000000000..9eb1ea7eb --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt @@ -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, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index 86db11af1..48e4ffc2a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -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)), ), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index f1f9b7fb6..247755b7a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -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, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt new file mode 100644 index 000000000..8434a4059 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -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, + ) + } +}