From d52114232b6ef4ea107c8ae21e2a2cfa1b679a67 Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 12 Dec 2023 10:26:34 -0600 Subject: [PATCH] BIT-502: Save the updated ciphers from the edit screen (#371) --- .../repository/util/DataStateExtensions.kt | 10 + .../feature/additem/VaultAddItemViewModel.kt | 155 ++++++++++++- .../additem/util/CipherViewExtensions.kt | 60 +++++ .../feature/vault/util/VaultDataExtensions.kt | 130 ++++++----- .../util/DataStateExtensionsTest.kt | 26 +++ .../additem/VaultAddItemViewModelTest.kt | 176 +++++++++++--- .../additem/util/CipherViewExtensionsTest.kt | 215 ++++++++++++++++++ .../vault/util/VaultDataExtensionsTest.kt | 169 +++++++++++++- 8 files changed, 843 insertions(+), 98 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensionsTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensions.kt index 14133ef519..ba5d0d3ac2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensions.kt @@ -1,6 +1,8 @@ package com.x8bit.bitwarden.data.platform.repository.util import com.x8bit.bitwarden.data.platform.repository.model.DataState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.transformWhile /** * Maps the data inside a [DataState] with the given [transform]. @@ -14,3 +16,11 @@ inline fun DataState.map( is DataState.Error -> DataState.Error(error, data?.let(transform)) is DataState.NoNetwork -> DataState.NoNetwork(data?.let(transform)) } + +/** + * Emits all values of a [DataState] [Flow] until it emits a [DataState.Loaded]. + */ +fun Flow>.takeUntilLoaded(): Flow> = transformWhile { + emit(it) + it !is DataState.Loaded +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt index 7f9f714e6c..767ac0ba08 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt @@ -4,19 +4,27 @@ import android.os.Parcelable import androidx.annotation.StringRes import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.bitwarden.core.CipherView import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult 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.vault.feature.additem.util.toViewState import com.x8bit.bitwarden.ui.vault.feature.vault.util.toCipherView import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -55,6 +63,19 @@ class VaultAddItemViewModel @Inject constructor( init { stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope) + + when (val vaultAddEditType = state.vaultAddEditType) { + VaultAddEditType.AddItem -> Unit + is VaultAddEditType.EditItem -> { + vaultRepository + .getVaultItemStateFlow(vaultAddEditType.vaultItemId) + // We'll stop getting updates as soon as we get some loaded data. + .takeUntilLoaded() + .map { VaultAddItemAction.Internal.VaultDataReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + } + } } override fun handleAction(action: VaultAddItemAction) { @@ -83,9 +104,21 @@ class VaultAddItemViewModel @Inject constructor( handleAddSecureNoteTypeAction(action) } + is VaultAddItemAction.Internal -> handleInternalActions(action) + } + } + + private fun handleInternalActions(action: VaultAddItemAction.Internal) { + when (action) { is VaultAddItemAction.Internal.CreateCipherResultReceive -> { handleCreateCipherResultReceive(action) } + + is VaultAddItemAction.Internal.UpdateCipherResultReceive -> { + handleUpdateCipherResultReceive(action) + } + + is VaultAddItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) } } @@ -115,13 +148,20 @@ class VaultAddItemViewModel @Inject constructor( } viewModelScope.launch { - sendAction( - action = VaultAddItemAction.Internal.CreateCipherResultReceive( - createCipherResult = vaultRepository.createCipher( + when (val vaultAddEditType = state.vaultAddEditType) { + VaultAddEditType.AddItem -> { + val result = vaultRepository.createCipher(cipherView = content.toCipherView()) + sendAction(VaultAddItemAction.Internal.CreateCipherResultReceive(result)) + } + + is VaultAddEditType.EditItem -> { + val result = vaultRepository.updateCipher( + cipherId = vaultAddEditType.vaultItemId, cipherView = content.toCipherView(), - ), - ), - ) + ) + sendAction(VaultAddItemAction.Internal.UpdateCipherResultReceive(result)) + } + } } } @@ -533,6 +573,80 @@ class VaultAddItemViewModel @Inject constructor( } } + private fun handleUpdateCipherResultReceive( + action: VaultAddItemAction.Internal.UpdateCipherResultReceive, + ) { + mutableStateFlow.update { it.copy(dialog = null) } + when (action.updateCipherResult) { + is UpdateCipherResult.Error -> { + // TODO Display error dialog BIT-501 + sendEvent(VaultAddItemEvent.ShowToast(message = "Save Item Failure")) + } + + is UpdateCipherResult.Success -> { + sendEvent(VaultAddItemEvent.NavigateBack) + } + } + } + + private fun handleVaultDataReceive(action: VaultAddItemAction.Internal.VaultDataReceive) { + when (val vaultDataState = action.vaultDataState) { + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + viewState = VaultAddItemState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + is DataState.Loaded -> { + mutableStateFlow.update { + it.copy( + viewState = vaultDataState + .data + ?.toViewState() + ?: VaultAddItemState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + DataState.Loading -> { + mutableStateFlow.update { + it.copy(viewState = VaultAddItemState.ViewState.Loading) + } + } + + is DataState.NoNetwork -> { + mutableStateFlow.update { + it.copy( + viewState = VaultAddItemState.ViewState.Error( + message = R.string.internet_connection_required_title + .asText() + .concat(R.string.internet_connection_required_message.asText()), + ), + ) + } + } + + is DataState.Pending -> { + mutableStateFlow.update { + it.copy( + viewState = vaultDataState + .data + ?.toViewState() + ?: VaultAddItemState.ViewState.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + } + } + //endregion Internal Type Handlers //region Utility Functions @@ -638,6 +752,14 @@ data class VaultAddItemState( */ @Parcelize sealed class Content : ViewState() { + /** + * The original cipher from the vault that the user is editing. + * + * This is only present when editing a pre-existing cipher. + */ + @IgnoredOnParcel + abstract val originalCipher: CipherView? + /** * Represents the resource ID for the display string. This is an abstract property * that must be overridden by each subclass to provide the appropriate string resource @@ -680,6 +802,8 @@ data class VaultAddItemState( */ @Parcelize data class Login( + @IgnoredOnParcel + override val originalCipher: CipherView? = null, override val name: String = "", val username: String = "", val password: String = "", @@ -706,6 +830,8 @@ data class VaultAddItemState( */ @Parcelize data class Card( + @IgnoredOnParcel + override val originalCipher: CipherView? = null, override val name: String = "", override val masterPasswordReprompt: Boolean = false, override val ownership: String = DEFAULT_OWNERSHIP, @@ -719,6 +845,8 @@ data class VaultAddItemState( */ @Parcelize data class Identity( + @IgnoredOnParcel + override val originalCipher: CipherView? = null, override val name: String = "", override val masterPasswordReprompt: Boolean = false, override val ownership: String = DEFAULT_OWNERSHIP, @@ -737,6 +865,8 @@ data class VaultAddItemState( */ @Parcelize data class SecureNotes( + @IgnoredOnParcel + override val originalCipher: CipherView? = null, override val name: String = "", val folderName: Text = DEFAULT_FOLDER, val favorite: Boolean = false, @@ -1006,6 +1136,12 @@ sealed class VaultAddItemAction { * Models actions that the [VaultAddItemViewModel] itself might send. */ sealed class Internal : VaultAddItemAction() { + /** + * Indicates that the vault item data has been received. + */ + data class VaultDataReceive( + val vaultDataState: DataState, + ) : Internal() /** * Indicates a result for creating a cipher has been received. @@ -1013,5 +1149,12 @@ sealed class VaultAddItemAction { data class CreateCipherResultReceive( val createCipherResult: CreateCipherResult, ) : Internal() + + /** + * Indicates a result for updating a cipher has been received. + */ + data class UpdateCipherResultReceive( + val updateCipherResult: UpdateCipherResult, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensions.kt new file mode 100644 index 0000000000..7743b43f0b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensions.kt @@ -0,0 +1,60 @@ +package com.x8bit.bitwarden.ui.vault.feature.additem.util + +import com.bitwarden.core.CipherRepromptType +import com.bitwarden.core.CipherType +import com.bitwarden.core.CipherView +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState + +/** + * Transforms [CipherView] into [VaultAddItemState.ViewState]. + */ +fun CipherView.toViewState(): VaultAddItemState.ViewState = + when (type) { + CipherType.LOGIN -> { + val loginView = requireNotNull(this.login) + VaultAddItemState.ViewState.Content.Login( + originalCipher = this, + name = this.name, + username = loginView.username.orEmpty(), + password = loginView.password.orEmpty(), + uri = loginView.uris?.firstOrNull()?.uri.orEmpty(), + favorite = this.favorite, + masterPasswordReprompt = this.reprompt == CipherRepromptType.PASSWORD, + notes = this.notes.orEmpty(), + // TODO: Update these properties to pull folder from data layer (BIT-501) + folderName = this.folderId?.asText() ?: R.string.folder_none.asText(), + availableFolders = emptyList(), + // TODO: Update this property to pull owner from data layer (BIT-501) + ownership = "", + // TODO: Update this property to pull available owners from data layer (BIT-501) + availableOwners = emptyList(), + ) + } + + CipherType.SECURE_NOTE -> { + VaultAddItemState.ViewState.Content.SecureNotes( + originalCipher = this, + name = this.name, + favorite = this.favorite, + masterPasswordReprompt = this.reprompt == CipherRepromptType.PASSWORD, + notes = this.notes.orEmpty(), + // TODO: Update these properties to pull folder from data layer (BIT-501) + folderName = this.folderId?.asText() ?: R.string.folder_none.asText(), + availableFolders = emptyList(), + // TODO: Update this property to pull owner from data layer (BIT-501) + ownership = "", + // TODO: Update this property to pull available owners from data layer (BIT-501) + availableOwners = emptyList(), + ) + } + + CipherType.CARD -> VaultAddItemState.ViewState.Error( + message = "Not yet implemented.".asText(), + ) + + CipherType.IDENTITY -> VaultAddItemState.ViewState.Error( + message = "Not yet implemented.".asText(), + ) + } 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 index 82e9a076c7..22769391ba 100644 --- 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 @@ -99,53 +99,53 @@ fun VaultAddItemState.ViewState.Content.toCipherView(): CipherView = */ private fun VaultAddItemState.ViewState.Content.Login.toLoginCipherView(): CipherView = CipherView( - id = null, - // TODO use real organization id BIT-780 - organizationId = null, - // TODO use real folder id BIT-528 - folderId = null, - collectionIds = emptyList(), - key = null, - name = name, - notes = notes, + // Pulled from original cipher when editing, otherwise uses defaults + id = this.originalCipher?.id, + collectionIds = this.originalCipher?.collectionIds.orEmpty(), + key = this.originalCipher?.key, + edit = this.originalCipher?.edit ?: true, + viewPassword = this.originalCipher?.viewPassword ?: true, + localData = this.originalCipher?.localData, + attachments = this.originalCipher?.attachments, + organizationUseTotp = this.originalCipher?.organizationUseTotp ?: false, + passwordHistory = this.originalCipher?.passwordHistory, + creationDate = this.originalCipher?.creationDate ?: Instant.now(), + deletedDate = this.originalCipher?.deletedDate, + revisionDate = this.originalCipher?.revisionDate ?: Instant.now(), + + // Type specific section type = CipherType.LOGIN, login = LoginView( - username = username, - password = password, - passwordRevisionDate = null, + username = this.username, + password = this.password, + passwordRevisionDate = this.originalCipher?.login?.passwordRevisionDate, uris = listOf( + // TODO Implement URI list (BIT-1094) LoginUriView( - uri = uri, - // TODO implement uri settings in BIT-1094 + uri = this.uri, + // TODO Implement URI settings in (BIT-1094) match = UriMatchType.DOMAIN, ), ), // TODO implement totp in BIT-1066 - totp = null, - autofillOnPageLoad = false, + totp = this.originalCipher?.login?.totp, + autofillOnPageLoad = this.originalCipher?.login?.autofillOnPageLoad, ), identity = null, card = null, secureNote = null, - favorite = favorite, - reprompt = if (masterPasswordReprompt) { - CipherRepromptType.PASSWORD - } else { - CipherRepromptType.NONE - }, - organizationUseTotp = false, - edit = true, - viewPassword = true, - localData = null, - attachments = null, - // TODO implement custom fields BIT-529 + + // Fields we always grab from the UI + name = this.name, + notes = this.notes, + favorite = this.favorite, + // TODO Use real folder ID (BIT-528) + folderId = this.originalCipher?.folderId, + // TODO Use real organization ID (BIT-780) + organizationId = this.originalCipher?.organizationId, + reprompt = this.toCipherRepromptType(), + // TODO Implement custom fields (BIT-529) fields = null, - passwordHistory = null, - creationDate = Instant.now(), - deletedDate = null, - // This is a throw away value. - // The SDK will eventually remove revisionDate via encryption. - revisionDate = Instant.now(), ) /** @@ -153,39 +153,38 @@ private fun VaultAddItemState.ViewState.Content.Login.toLoginCipherView(): Ciphe */ private fun VaultAddItemState.ViewState.Content.SecureNotes.toSecureNotesCipherView(): CipherView = CipherView( - id = null, - // TODO use real organization id BIT-780 - organizationId = null, - // TODO use real folder id BIT-528 - folderId = null, - collectionIds = emptyList(), - key = null, - name = name, - notes = notes, + // Pulled from original cipher when editing, otherwise uses defaults + id = this.originalCipher?.id, + collectionIds = this.originalCipher?.collectionIds.orEmpty(), + key = this.originalCipher?.key, + edit = this.originalCipher?.edit ?: true, + viewPassword = this.originalCipher?.viewPassword ?: true, + localData = this.originalCipher?.localData, + attachments = this.originalCipher?.attachments, + organizationUseTotp = this.originalCipher?.organizationUseTotp ?: false, + passwordHistory = this.originalCipher?.passwordHistory, + creationDate = this.originalCipher?.creationDate ?: Instant.now(), + deletedDate = this.originalCipher?.deletedDate, + revisionDate = this.originalCipher?.revisionDate ?: Instant.now(), + + // Type specific section type = CipherType.SECURE_NOTE, - secureNote = SecureNoteView(SecureNoteType.GENERIC), + secureNote = SecureNoteView(type = SecureNoteType.GENERIC), login = null, identity = null, card = null, - favorite = favorite, - reprompt = if (masterPasswordReprompt) { - CipherRepromptType.PASSWORD - } else { - CipherRepromptType.NONE - }, - organizationUseTotp = false, - edit = true, - viewPassword = true, - localData = null, - attachments = null, - // TODO implement custom fields BIT-529 + + // Fields we always grab from the UI + name = this.name, + notes = this.notes, + favorite = this.favorite, + // TODO Use real folder ID (BIT-528) + folderId = this.originalCipher?.folderId, + // TODO Use real organization ID (BIT-780) + organizationId = this.originalCipher?.organizationId, + reprompt = this.toCipherRepromptType(), + // TODO Implement custom fields (BIT-529) fields = null, - passwordHistory = null, - creationDate = Instant.now(), - deletedDate = null, - // This is a throw away value. - // The SDK will eventually remove revisionDate via encryption. - revisionDate = Instant.now(), ) /** @@ -199,3 +198,10 @@ private fun VaultAddItemState.ViewState.Content.Identity.toIdentityCipherView(): */ private fun VaultAddItemState.ViewState.Content.Card.toCardCipherView(): CipherView = TODO("create Card CipherView BIT-668") + +private fun VaultAddItemState.ViewState.Content.toCipherRepromptType(): CipherRepromptType = + if (this.masterPasswordReprompt) { + CipherRepromptType.PASSWORD + } else { + CipherRepromptType.NONE + } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensionsTest.kt new file mode 100644 index 0000000000..7d6fe2b455 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensionsTest.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.data.platform.repository.util + +import app.cash.turbine.test +import com.x8bit.bitwarden.data.platform.repository.model.DataState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DataStateExtensionsTest { + + @Test + fun `takeUtilLoaded should complete after a Loaded state is emitted`() = runTest { + val mutableStateFlow = MutableStateFlow>(DataState.Loading) + mutableStateFlow + .takeUntilLoaded() + .test { + assertEquals(DataState.Loading, awaitItem()) + mutableStateFlow.value = DataState.NoNetwork(Unit) + assertEquals(DataState.NoNetwork(Unit), awaitItem()) + mutableStateFlow.value = DataState.Loaded(Unit) + assertEquals(DataState.Loaded(Unit), awaitItem()) + awaitComplete() + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt index 2fbe16145b..c1f801dc04 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt @@ -2,16 +2,27 @@ package com.x8bit.bitwarden.ui.vault.feature.additem import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.bitwarden.core.CipherView import com.x8bit.bitwarden.R +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.CreateCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.vault.feature.additem.util.toViewState import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested @@ -24,7 +35,20 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { state = initialState, vaultAddEditType = VaultAddEditType.AddItem, ) - private val vaultRepository: VaultRepository = mockk() + private val mutableVaultItemFlow = MutableStateFlow>(DataState.Loading) + private val vaultRepository: VaultRepository = mockk { + every { getVaultItemStateFlow(DEFAULT_EDIT_ITEM_ID) } returns mutableVaultItemFlow + } + + @BeforeEach + fun setup() { + mockkStatic(CIPHER_VIEW_EXTENSIONS_PATH) + } + + @AfterEach + fun tearDown() { + unmockkStatic(CIPHER_VIEW_EXTENSIONS_PATH) + } @Test fun `initial state should be correct when state is null`() = runTest { @@ -50,6 +74,9 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { ), ) assertEquals(initState, viewModel.stateFlow.value) + verify(exactly = 0) { + vaultRepository.getVaultItemStateFlow(DEFAULT_EDIT_ITEM_ID) + } } @Test @@ -62,7 +89,13 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { vaultAddEditType = vaultAddEditType, ), ) - assertEquals(initState, viewModel.stateFlow.value) + assertEquals( + initState.copy(viewState = VaultAddItemState.ViewState.Loading), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { + vaultRepository.getVaultItemStateFlow(DEFAULT_EDIT_ITEM_ID) + } } @Test @@ -75,38 +108,44 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { } @Test - fun `SaveClick should show dialog, and remove it once an item is saved`() = runTest { - val stateWithDialog = createVaultAddLoginItemState( - name = "tester", - dialogState = VaultAddItemState.DialogState.Loading( - R.string.saving.asText(), - ), - ) + fun `in add mode, SaveClick should show dialog, and remove it once an item is saved`() = + runTest { + val stateWithDialog = createVaultAddLoginItemState( + name = "tester", + dialogState = VaultAddItemState.DialogState.Loading( + R.string.saving.asText(), + ), + ) - val stateWithName = createVaultAddLoginItemState( - name = "tester", - ) + val stateWithName = createVaultAddLoginItemState( + name = "tester", + ) - val viewModel = createAddVaultItemViewModel( - createSavedStateHandleWithState( - state = stateWithName, - vaultAddEditType = VaultAddEditType.AddItem, - ), - ) + val viewModel = createAddVaultItemViewModel( + createSavedStateHandleWithState( + state = stateWithName, + vaultAddEditType = VaultAddEditType.AddItem, + ), + ) - coEvery { - vaultRepository.createCipher(any()) - } returns CreateCipherResult.Success - viewModel.stateFlow.test { - viewModel.actionChannel.trySend(VaultAddItemAction.SaveClick) - assertEquals(stateWithName, awaitItem()) - assertEquals(stateWithDialog, awaitItem()) - assertEquals(stateWithName, awaitItem()) + coEvery { + vaultRepository.createCipher(any()) + } returns CreateCipherResult.Success + + viewModel.stateFlow.test { + viewModel.actionChannel.trySend(VaultAddItemAction.SaveClick) + assertEquals(stateWithName, awaitItem()) + assertEquals(stateWithDialog, awaitItem()) + assertEquals(stateWithName, awaitItem()) + } + + coVerify(exactly = 1) { + vaultRepository.createCipher(any()) + } } - } @Test - fun `SaveClick should update value to loading`() = runTest { + fun `in add mode, SaveClick should update value to loading`() = runTest { val stateWithName = createVaultAddLoginItemState( name = "tester", ) @@ -128,7 +167,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { } @Test - fun `SaveClick createCipher error should emit ShowToast`() = runTest { + fun `in add mode, SaveClick createCipher error should emit ShowToast`() = runTest { val stateWithName = createVaultAddLoginItemState( name = "tester", ) @@ -149,6 +188,82 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { } } + @Test + fun `in edit mode, SaveClick should show dialog, and remove it once an item is saved`() = + runTest { + val cipherView = mockk() + val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) + val stateWithDialog = createVaultAddLoginItemState( + vaultAddEditType = vaultAddEditType, + name = "tester", + dialogState = VaultAddItemState.DialogState.Loading( + R.string.saving.asText(), + ), + ) + + val stateWithName = createVaultAddLoginItemState( + vaultAddEditType = vaultAddEditType, + name = "tester", + ) + every { cipherView.toViewState() } returns stateWithName.viewState + mutableVaultItemFlow.value = DataState.Loaded(cipherView) + + val viewModel = createAddVaultItemViewModel( + createSavedStateHandleWithState( + state = stateWithName, + vaultAddEditType = vaultAddEditType, + ), + ) + + coEvery { + vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any()) + } returns UpdateCipherResult.Success + + viewModel.stateFlow.test { + assertEquals(stateWithName, awaitItem()) + viewModel.actionChannel.trySend(VaultAddItemAction.SaveClick) + assertEquals(stateWithDialog, awaitItem()) + assertEquals(stateWithName, awaitItem()) + } + + coVerify(exactly = 1) { + cipherView.toViewState() + vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any()) + } + } + + @Test + fun `in edit mode, SaveClick createCipher error should emit ShowToast`() = runTest { + val cipherView = mockk() + val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) + val stateWithName = createVaultAddLoginItemState( + vaultAddEditType = vaultAddEditType, + name = "tester", + ) + + every { cipherView.toViewState() } returns stateWithName.viewState + coEvery { + vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any()) + } returns UpdateCipherResult.Error + mutableVaultItemFlow.value = DataState.Loaded(cipherView) + + val viewModel = createAddVaultItemViewModel( + createSavedStateHandleWithState( + state = stateWithName, + vaultAddEditType = vaultAddEditType, + ), + ) + + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(VaultAddItemAction.SaveClick) + assertEquals(VaultAddItemEvent.ShowToast("Save Item Failure"), awaitItem()) + } + + coVerify(exactly = 1) { + vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any()) + } + } + @Test fun `Saving item with an empty name field will cause a dialog to show up`() = runTest { val stateWithNoName = createVaultAddSecureNotesItemState(name = "") @@ -712,4 +827,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { ) } +private const val CIPHER_VIEW_EXTENSIONS_PATH: String = + "com.x8bit.bitwarden.ui.vault.feature.additem.util.CipherViewExtensionsKt" + private const val DEFAULT_EDIT_ITEM_ID: String = "edit_item_id" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensionsTest.kt new file mode 100644 index 0000000000..0f742e1db5 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensionsTest.kt @@ -0,0 +1,215 @@ +package com.x8bit.bitwarden.ui.vault.feature.additem.util + +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.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.Instant + +class CipherViewExtensionsTest { + + @Test + fun `toViewState should create a Card ViewState`() { + val cipherView = DEFAULT_CARD_CIPHER_VIEW + + val result = cipherView.toViewState() + + assertEquals( + VaultAddItemState.ViewState.Error(message = "Not yet implemented.".asText()), + result, + ) + } + + @Test + fun `toViewState should create a Identity ViewState`() { + val cipherView = DEFAULT_IDENTITY_CIPHER_VIEW + + val result = cipherView.toViewState() + + assertEquals( + VaultAddItemState.ViewState.Error(message = "Not yet implemented.".asText()), + result, + ) + } + + @Test + fun `toViewState should create a Login ViewState`() { + val cipherView = DEFAULT_LOGIN_CIPHER_VIEW + + val result = cipherView.toViewState() + + assertEquals( + VaultAddItemState.ViewState.Content.Login( + originalCipher = cipherView, + name = "cipher", + username = "username", + password = "password", + uri = "www.example.com", + folderName = R.string.folder_none.asText(), + favorite = false, + masterPasswordReprompt = true, + notes = "Lots of notes", + ownership = "", + availableFolders = emptyList(), + availableOwners = emptyList(), + ), + result, + ) + } + + @Test + fun `toViewState should create a Secure Notes ViewState`() { + val cipherView = DEFAULT_SECURE_NOTES_CIPHER_VIEW + + val result = cipherView.toViewState() + + assertEquals( + VaultAddItemState.ViewState.Content.SecureNotes( + originalCipher = cipherView, + name = "cipher", + folderName = R.string.folder_none.asText(), + favorite = false, + masterPasswordReprompt = true, + notes = "Lots of notes", + ownership = "", + availableFolders = emptyList(), + availableOwners = emptyList(), + ), + result, + ) + } +} + +private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView( + id = "id1234", + organizationId = null, + folderId = null, + collectionIds = emptyList(), + key = null, + name = "cipher", + notes = "Lots of notes", + type = CipherType.LOGIN, + login = null, + identity = null, + card = null, + secureNote = null, + favorite = false, + reprompt = CipherRepromptType.PASSWORD, + organizationUseTotp = false, + edit = false, + viewPassword = false, + localData = null, + attachments = null, + fields = listOf( + FieldView( + name = "text", + value = "value", + type = FieldType.TEXT, + linkedId = null, + ), + FieldView( + name = "hidden", + value = "value", + type = FieldType.HIDDEN, + linkedId = null, + ), + FieldView( + name = "boolean", + value = "true", + type = FieldType.BOOLEAN, + linkedId = null, + ), + FieldView( + name = "linked username", + value = null, + type = FieldType.LINKED, + linkedId = 100U, + ), + FieldView( + name = "linked password", + value = null, + type = FieldType.LINKED, + linkedId = 101U, + ), + ), + passwordHistory = listOf( + PasswordHistoryView( + password = "old_password", + lastUsedDate = Instant.ofEpochSecond(1_000L), + ), + ), + creationDate = Instant.ofEpochSecond(1_000L), + deletedDate = null, + revisionDate = Instant.ofEpochSecond(1_000L), +) + +private val DEFAULT_CARD_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy( + type = CipherType.CARD, + card = CardView( + cardholderName = "Bit Warden", + expMonth = "04", + expYear = "2030", + code = "123", + brand = "Visa", + number = "4012888888881881", + ), +) + +private val DEFAULT_IDENTITY_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy( + type = CipherType.IDENTITY, + identity = IdentityView( + title = "Dr.", + firstName = "John", + lastName = "Smith", + middleName = "Richard", + address1 = null, + address2 = null, + address3 = null, + city = "Minneapolis", + state = "MN", + postalCode = null, + country = "USA", + company = "Bitwarden", + email = "placeholde@email.com", + phone = "555-555-5555", + ssn = null, + username = "Dr. JSR", + passportNumber = null, + licenseNumber = null, + ), +) + +private val DEFAULT_LOGIN_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy( + type = CipherType.LOGIN, + login = LoginView( + username = "username", + password = "password", + passwordRevisionDate = Instant.ofEpochSecond(1_000L), + uris = listOf( + LoginUriView( + uri = "www.example.com", + match = null, + ), + ), + totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + autofillOnPageLoad = false, + ), +) + +private val DEFAULT_SECURE_NOTES_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy( + type = CipherType.SECURE_NOTE, + secureNote = SecureNoteView(type = SecureNoteType.GENERIC), +) 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 index bf581adf1b..1d3a73ff95 100644 --- 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 @@ -3,8 +3,11 @@ package com.x8bit.bitwarden.ui.vault.feature.vault.util 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.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 @@ -144,7 +147,7 @@ class VaultDataExtensionsTest { ), ), totp = null, - autofillOnPageLoad = false, + autofillOnPageLoad = null, ), identity = null, card = null, @@ -166,6 +169,57 @@ class VaultDataExtensionsTest { ) } + @Test + fun `toCipherView should transform Login ItemType to CipherView with original cipher`() { + val cipherView = DEFAULT_LOGIN_CIPHER_VIEW + val loginItemType = VaultAddItemState.ViewState.Content.Login( + originalCipher = cipherView, + name = "mockName-1", + username = "mockUsername-1", + password = "mockPassword-1", + uri = "mockUri-1", + folderName = "mockFolder-1".asText(), + favorite = true, + masterPasswordReprompt = false, + notes = "mockNotes-1", + ownership = "mockOwnership-1", + ) + + val result = loginItemType.toCipherView() + + assertEquals( + @Suppress("MaxLineLength") + cipherView.copy( + name = "mockName-1", + notes = "mockNotes-1", + type = CipherType.LOGIN, + login = LoginView( + username = "mockUsername-1", + password = "mockPassword-1", + passwordRevisionDate = Instant.ofEpochSecond(1_000L), + uris = listOf( + LoginUriView( + uri = "mockUri-1", + match = UriMatchType.DOMAIN, + ), + ), + totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + autofillOnPageLoad = false, + ), + favorite = true, + reprompt = CipherRepromptType.NONE, + fields = null, + passwordHistory = listOf( + PasswordHistoryView( + password = "old_password", + lastUsedDate = Instant.ofEpochSecond(1_000L), + ), + ), + ), + result, + ) + } + @Test fun `toCipherView should transform SecureNotes ItemType to CipherView`() { mockkStatic(Instant::class) @@ -211,4 +265,117 @@ class VaultDataExtensionsTest { result, ) } + + @Test + fun `toCipherView should transform SecureNotes ItemType to CipherView with original cipher`() { + val cipherView = DEFAULT_SECURE_NOTES_CIPHER_VIEW + val secureNotesItemType = VaultAddItemState.ViewState.Content.SecureNotes( + originalCipher = cipherView, + name = "mockName-1", + folderName = "mockFolder-1".asText(), + favorite = false, + masterPasswordReprompt = true, + notes = "mockNotes-1", + ownership = "mockOwnership-1", + ) + + val result = secureNotesItemType.toCipherView() + + assertEquals( + cipherView.copy( + name = "mockName-1", + notes = "mockNotes-1", + type = CipherType.SECURE_NOTE, + secureNote = SecureNoteView(SecureNoteType.GENERIC), + reprompt = CipherRepromptType.PASSWORD, + fields = null, + ), + result, + ) + } } + +private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView( + id = "id1234", + organizationId = null, + folderId = null, + collectionIds = emptyList(), + key = null, + name = "cipher", + notes = "Lots of notes", + type = CipherType.LOGIN, + login = null, + identity = null, + card = null, + secureNote = null, + favorite = false, + reprompt = CipherRepromptType.PASSWORD, + organizationUseTotp = false, + edit = false, + viewPassword = false, + localData = null, + attachments = null, + fields = listOf( + FieldView( + name = "text", + value = "value", + type = FieldType.TEXT, + linkedId = null, + ), + FieldView( + name = "hidden", + value = "value", + type = FieldType.HIDDEN, + linkedId = null, + ), + FieldView( + name = "boolean", + value = "true", + type = FieldType.BOOLEAN, + linkedId = null, + ), + FieldView( + name = "linked username", + value = null, + type = FieldType.LINKED, + linkedId = 100U, + ), + FieldView( + name = "linked password", + value = null, + type = FieldType.LINKED, + linkedId = 101U, + ), + ), + passwordHistory = listOf( + PasswordHistoryView( + password = "old_password", + lastUsedDate = Instant.ofEpochSecond(1_000L), + ), + ), + creationDate = Instant.ofEpochSecond(1_000L), + deletedDate = null, + revisionDate = Instant.ofEpochSecond(1_000L), +) + +private val DEFAULT_LOGIN_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy( + type = CipherType.LOGIN, + login = LoginView( + username = "username", + password = "password", + passwordRevisionDate = Instant.ofEpochSecond(1_000L), + uris = listOf( + LoginUriView( + uri = "www.example.com", + match = null, + ), + ), + totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + autofillOnPageLoad = false, + ), +) + +private val DEFAULT_SECURE_NOTES_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy( + type = CipherType.SECURE_NOTE, + secureNote = SecureNoteView(type = SecureNoteType.GENERIC), +)