diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt index 6282193fc..6c6bc0255 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt @@ -39,10 +39,18 @@ interface CiphersApi { ): Result /** - * Deletes a cipher. + * Hard deletes a cipher. */ @DELETE("ciphers/{cipherId}") - suspend fun deleteCipher( + suspend fun hardDeleteCipher( + @Path("cipherId") cipherId: String, + ): Result + + /** + * Soft deletes a cipher. + */ + @PUT("ciphers/{cipherId}/delete") + suspend fun softDeleteCipher( @Path("cipherId") cipherId: String, ): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt index 6e977fcc3..4ace194c1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt @@ -31,7 +31,12 @@ interface CiphersService { ): Result /** - * Attempt to delete a cipher. + * Attempt to hard delete a cipher. */ - suspend fun deleteCipher(cipherId: String): Result + suspend fun hardDeleteCipher(cipherId: String): Result + + /** + * Attempt to soft delete a cipher. + */ + suspend fun softDeleteCipher(cipherId: String): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt index 03dcbfc20..62c3defad 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt @@ -45,6 +45,9 @@ class CiphersServiceImpl constructor( body = body, ) - override suspend fun deleteCipher(cipherId: String): Result = - ciphersApi.deleteCipher(cipherId = cipherId) + override suspend fun hardDeleteCipher(cipherId: String): Result = + ciphersApi.hardDeleteCipher(cipherId = cipherId) + + override suspend fun softDeleteCipher(cipherId: String): Result = + ciphersApi.softDeleteCipher(cipherId = cipherId) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index 0b7549d97..a254bcda3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -162,7 +162,15 @@ interface VaultRepository : VaultLockManager { /** * Attempt to delete a cipher. */ - suspend fun deleteCipher(cipherId: String): DeleteCipherResult + suspend fun hardDeleteCipher(cipherId: String): DeleteCipherResult + + /** + * Attempt to soft delete a cipher. + */ + suspend fun softDeleteCipher( + cipherId: String, + cipherView: CipherView, + ): DeleteCipherResult /** * Attempt to update a cipher. 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 edae682d6..2b50e6bc8 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 @@ -48,6 +48,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher +import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkSend import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollectionList @@ -71,6 +72,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.Instant /** * A "stop timeout delay" in milliseconds used to let a shared coroutine continue to run for the @@ -383,10 +385,10 @@ class VaultRepositoryImpl( ) } - override suspend fun deleteCipher(cipherId: String): DeleteCipherResult { + override suspend fun hardDeleteCipher(cipherId: String): DeleteCipherResult { val userId = requireNotNull(activeUserId) return ciphersService - .deleteCipher(cipherId) + .hardDeleteCipher(cipherId) .onSuccess { vaultDiskSource.deleteCipher(userId, cipherId) } .fold( onSuccess = { DeleteCipherResult.Success }, @@ -394,6 +396,34 @@ class VaultRepositoryImpl( ) } + override suspend fun softDeleteCipher( + cipherId: String, + cipherView: CipherView, + ): DeleteCipherResult { + val userId = requireNotNull(activeUserId) + return ciphersService + .softDeleteCipher(cipherId) + .fold( + onSuccess = { + vaultSdkSource + .encryptCipher( + userId = userId, + cipherView = cipherView.copy( + deletedDate = Instant.now(), + ), + ) + .onSuccess { cipher -> + vaultDiskSource.saveCipher( + userId = userId, + cipher = cipher.toEncryptedNetworkCipherResponse(), + ) + } + DeleteCipherResult.Success + }, + onFailure = { DeleteCipherResult.Error }, + ) + } + override suspend fun updateCipher( cipherId: String, cipherView: CipherView, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt index f3c831f5b..b507d89cc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt @@ -49,6 +49,37 @@ fun Cipher.toEncryptedNetworkCipher(): CipherJsonRequest = card = card?.toEncryptedNetworkCard(), ) +/** + * Converts a Bitwarden SDK [Cipher] object to a corresponding + * [SyncResponseJson.Cipher] object. + */ +fun Cipher.toEncryptedNetworkCipherResponse(): SyncResponseJson.Cipher = + SyncResponseJson.Cipher( + notes = notes, + reprompt = reprompt.toNetworkRepromptType(), + passwordHistory = passwordHistory?.toEncryptedNetworkPasswordHistoryList(), + type = type.toNetworkCipherType(), + login = login?.toEncryptedNetworkLogin(), + secureNote = secureNote?.toEncryptedNetworkSecureNote(), + folderId = folderId, + organizationId = organizationId, + identity = identity?.toEncryptedNetworkIdentity(), + name = name, + fields = fields?.toEncryptedNetworkFieldList(), + isFavorite = favorite, + card = card?.toEncryptedNetworkCard(), + attachments = attachments?.toNetworkAttachmentList(), + shouldOrganizationUseTotp = organizationUseTotp, + shouldEdit = edit, + revisionDate = ZonedDateTime.ofInstant(revisionDate, ZoneOffset.UTC), + creationDate = ZonedDateTime.ofInstant(creationDate, ZoneOffset.UTC), + deletedDate = deletedDate?.let { ZonedDateTime.ofInstant(it, ZoneOffset.UTC) }, + collectionIds = collectionIds, + id = id.orEmpty(), + shouldViewPassword = viewPassword, + key = key, + ) + /** * Converts a Bitwarden SDK [Card] object to a corresponding * [SyncResponseJson.Cipher.Card] object. @@ -161,6 +192,27 @@ private fun UriMatchType.toNetworkMatchType(): UriMatchTypeJson = UriMatchType.NEVER -> UriMatchTypeJson.NEVER } +/** + * Converts a list of Bitwarden SDK [Attachment] objects to a corresponding + * [SyncResponseJson.Cipher.Attachment] list. + */ +private fun List.toNetworkAttachmentList(): List = + map { it.toNetworkAttachment() } + +/** + * Converts a Bitwarden SDK [Attachment] object to a corresponding + * [SyncResponseJson.Cipher.Attachment] object. + */ +private fun Attachment.toNetworkAttachment(): SyncResponseJson.Cipher.Attachment = + SyncResponseJson.Cipher.Attachment( + fileName = fileName, + size = size?.toInt() ?: 0, + sizeName = sizeName, + id = id, + url = url, + key = key, + ) + /** * Converts a Bitwarden SDK [Login] object to a corresponding * [SyncResponseJson.Cipher.Login] object. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index a2ace6a03..6bacb7b0b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -15,7 +15,10 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -27,7 +30,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect -import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent @@ -37,6 +39,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenMasterPasswordDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager @@ -62,6 +65,11 @@ fun VaultItemScreen( val state by viewModel.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current val resources = context.resources + val confirmDeleteClickAction = remember(viewModel) { + { viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick) } + } + var pendingDeleteCipher by rememberSaveable { mutableStateOf(false) } + EventsEffect(viewModel = viewModel) { event -> when (event) { VaultItemEvent.NavigateBack -> onNavigateBack() @@ -103,6 +111,25 @@ fun VaultItemScreen( }, ) + if (pendingDeleteCipher) { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.delete), + message = stringResource(id = R.string.do_you_really_want_to_soft_delete_cipher), + confirmButtonText = stringResource(id = R.string.ok), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + pendingDeleteCipher = false + confirmDeleteClickAction() + }, + onDismissClick = { + pendingDeleteCipher = false + }, + onDismissRequest = { + pendingDeleteCipher = false + }, + ) + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( modifier = Modifier @@ -123,9 +150,7 @@ fun VaultItemScreen( menuItemDataList = persistentListOf( OverflowMenuItemData( text = stringResource(id = R.string.delete), - onClick = remember(viewModel) { - { viewModel.trySendAction(VaultItemAction.Common.DeleteClick) } - }, + onClick = { pendingDeleteCipher = true }, ), OverflowMenuItemData( text = stringResource(id = R.string.attachments), @@ -213,8 +238,8 @@ private fun VaultItemDialogs( onDismissRequest = onDismissRequest, ) - VaultItemState.DialogState.Loading -> BitwardenLoadingDialog( - visibilityState = LoadingDialogState.Shown(text = R.string.loading.asText()), + is VaultItemState.DialogState.Loading -> BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(text = dialog.message), ) VaultItemState.DialogState.MasterPasswordDialog -> { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index 7ca64d313..b29da1cbd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -1,7 +1,6 @@ package com.x8bit.bitwarden.ui.vault.feature.item import android.os.Parcelable -import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.core.CipherView @@ -12,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager 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.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.VerifyPasswordResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.launchIn 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 @@ -67,7 +68,6 @@ class VaultItemViewModel @Inject constructor( } override fun handleAction(action: VaultItemAction) { - Log.d("ramsey", "handleAction: action $action") when (action) { is VaultItemAction.ItemType.Login -> handleLoginTypeActions(action) is VaultItemAction.ItemType.Card -> handleCardTypeActions(action) @@ -100,8 +100,8 @@ class VaultItemViewModel @Inject constructor( is VaultItemAction.Common.AttachmentsClick -> handleAttachmentsClick() is VaultItemAction.Common.CloneClick -> handleCloneClick() - is VaultItemAction.Common.DeleteClick -> handleDeleteClick() is VaultItemAction.Common.MoveToOrganizationClick -> handleMoveToOrganizationClick() + is VaultItemAction.Common.ConfirmDeleteClick -> handleConfirmDeleteClick() } } @@ -132,7 +132,7 @@ class VaultItemViewModel @Inject constructor( private fun handleMasterPasswordSubmit(action: VaultItemAction.Common.MasterPasswordSubmit) { mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.Loading) + it.copy(dialog = VaultItemState.DialogState.Loading(R.string.loading.asText())) } viewModelScope.launch { @Suppress("MagicNumber") @@ -215,15 +215,37 @@ class VaultItemViewModel @Inject constructor( ) } - private fun handleDeleteClick() { - // TODO Implement delete in BIT-1408 - sendEvent(VaultItemEvent.ShowToast("Not yet implemented.".asText())) - } - private fun handleMoveToOrganizationClick() { sendEvent(VaultItemEvent.NavigateToMoveToOrganization(itemId = state.vaultItemId)) } + private fun handleConfirmDeleteClick() { + mutableStateFlow.update { + it.copy( + dialog = VaultItemState.DialogState.Loading( + R.string.soft_deleting.asText(), + ), + ) + } + onContent { content -> + content + .common + .currentCipher + ?.let { cipher -> + viewModelScope.launch { + trySendAction( + VaultItemAction.Internal.DeleteCipherReceive( + result = vaultRepository.softDeleteCipher( + cipherId = state.vaultItemId, + cipherView = cipher, + ), + ), + ) + } + } + } + } + //endregion Common Handlers //region Login Type Handlers @@ -264,7 +286,7 @@ class VaultItemViewModel @Inject constructor( onLoginContent { _, login -> val password = requireNotNull(login.passwordData?.password) mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.Loading) + it.copy(dialog = VaultItemState.DialogState.Loading(R.string.loading.asText())) } viewModelScope.launch { val result = authRepository.getPasswordBreachCount(password = password) @@ -391,6 +413,7 @@ class VaultItemViewModel @Inject constructor( is VaultItemAction.Internal.PasswordBreachReceive -> handlePasswordBreachReceive(action) is VaultItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) is VaultItemAction.Internal.VerifyPasswordReceive -> handleVerifyPasswordReceive(action) + is VaultItemAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action) } } @@ -503,6 +526,25 @@ class VaultItemViewModel @Inject constructor( } } + private fun handleDeleteCipherReceive(action: VaultItemAction.Internal.DeleteCipherReceive) { + when (action.result) { + DeleteCipherResult.Error -> { + mutableStateFlow.update { + it.copy( + dialog = VaultItemState.DialogState.Generic( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + DeleteCipherResult.Success -> { + mutableStateFlow.update { it.copy(dialog = null) } + sendEvent(VaultItemEvent.ShowToast(message = R.string.item_soft_deleted.asText())) + sendEvent(VaultItemEvent.NavigateBack) + } + } + } + //endregion Internal Type Handlers private inline fun onContent( @@ -589,6 +631,7 @@ data class VaultItemState( * @property customFields A list of custom fields that user has added. * @property requiresReprompt Indicates if a master password prompt is required to view * secure fields. + * @property currentCipher The cipher that is currently being viewed (nullable). */ @Parcelize data class Common( @@ -597,6 +640,8 @@ data class VaultItemState( val notes: String?, val customFields: List, val requiresReprompt: Boolean, + @IgnoredOnParcel + val currentCipher: CipherView? = null, ) : Parcelable { /** @@ -766,10 +811,12 @@ data class VaultItemState( ) : DialogState() /** - * Displays the loading dialog to the user. + * Displays the loading dialog to the user with a message. */ @Parcelize - data object Loading : DialogState() + data class Loading( + val message: Text, + ) : DialogState() /** * Displays the master password dialog to the user. @@ -848,6 +895,11 @@ sealed class VaultItemAction { */ data object CloseClick : Common() + /** + * The user has confirmed to deleted the cipher. + */ + data object ConfirmDeleteClick : Common() + /** * The user has clicked to dismiss the dialog. */ @@ -892,11 +944,6 @@ sealed class VaultItemAction { val isVisible: Boolean, ) : Common() - /** - * The user has clicked the delete button. - */ - data object DeleteClick : Common() - /** * The user has clicked the attachments button. */ @@ -1006,5 +1053,12 @@ sealed class VaultItemAction { data class VerifyPasswordReceive( val result: VerifyPasswordResult, ) : Internal() + + /** + * Indicates that the delete cipher result has been received. + */ + data class DeleteCipherReceive( + val result: DeleteCipherResult, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt index 8925462d7..758a1979c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt @@ -34,6 +34,7 @@ fun CipherView.toViewState( ): VaultItemState.ViewState = VaultItemState.ViewState.Content( common = VaultItemState.ViewState.Content.Common( + currentCipher = this, name = name, requiresReprompt = reprompt == CipherRepromptType.PASSWORD, customFields = fields.orEmpty().map { it.toCustomField() }, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt index 3a3b8f503..43bb48f31 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt @@ -66,10 +66,18 @@ class CiphersServiceTest : BaseServiceTest() { } @Test - fun `deleteCipher should execute the delete cipher API`() = runTest { + fun `hardDeleteCipher should execute the hardDeleteCipher API`() = runTest { server.enqueue(MockResponse().setResponseCode(200)) val cipherId = "cipherId" - val result = ciphersService.deleteCipher(cipherId = cipherId) + val result = ciphersService.hardDeleteCipher(cipherId = cipherId) + assertEquals(Unit, result.getOrThrow()) + } + + @Test + fun `softDeleteCipher should execute the softDeleteCipher API`() = runTest { + server.enqueue(MockResponse().setResponseCode(200)) + val cipherId = "cipherId" + val result = ciphersService.softDeleteCipher(cipherId = cipherId) assertEquals(Unit, result.getOrThrow()) } 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 b94bc7352..9eb3f4a6e 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 @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.vault.repository import android.net.Uri import app.cash.turbine.test +import com.bitwarden.core.Cipher import com.bitwarden.core.CipherView import com.bitwarden.core.CollectionView import com.bitwarden.core.DateTime @@ -68,6 +69,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.data.vault.repository.model.VaultState import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult +import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollectionList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList @@ -90,6 +92,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.net.UnknownHostException +import java.time.Instant @Suppress("LargeClass") class VaultRepositoryTest { @@ -1549,34 +1552,95 @@ class VaultRepositoryTest { @Suppress("MaxLineLength") @Test - fun `deleteCipher with ciphersService deleteCipher failure should return DeleteCipherResult Error`() = + fun `hardDeleteCipher with ciphersService hardDeleteCipher failure should return DeleteCipherResult Error`() = runTest { fakeAuthDiskSource.userState = MOCK_USER_STATE val cipherId = "mockId-1" coEvery { - ciphersService.deleteCipher(cipherId = cipherId) + ciphersService.hardDeleteCipher(cipherId = cipherId) } returns Throwable("Fail").asFailure() - val result = vaultRepository.deleteCipher(cipherId) + val result = vaultRepository.hardDeleteCipher(cipherId) assertEquals(DeleteCipherResult.Error, result) } @Suppress("MaxLineLength") @Test - fun `deleteCipher with ciphersService deleteCipher success should return DeleteCipherResult success`() = + fun `hardDeleteCipher with ciphersService hardDeleteCipher success should return DeleteCipherResult success`() = runTest { fakeAuthDiskSource.userState = MOCK_USER_STATE val userId = "mockId-1" val cipherId = "mockId-1" - coEvery { ciphersService.deleteCipher(cipherId = cipherId) } returns Unit.asSuccess() + coEvery { ciphersService.hardDeleteCipher(cipherId = cipherId) } returns Unit.asSuccess() coEvery { vaultDiskSource.deleteCipher(userId, cipherId) } just runs - val result = vaultRepository.deleteCipher(cipherId) + val result = vaultRepository.hardDeleteCipher(cipherId) assertEquals(DeleteCipherResult.Success, result) } + @Suppress("MaxLineLength") + @Test + fun `softDeleteCipher with ciphersService softDeleteCipher failure should return DeleteCipherResult Error`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val cipherId = "mockId-1" + coEvery { + ciphersService.softDeleteCipher(cipherId = cipherId) + } returns Throwable("Fail").asFailure() + + val result = vaultRepository.softDeleteCipher( + cipherId = cipherId, + cipherView = createMockCipherView(number = 1), + ) + + assertEquals(DeleteCipherResult.Error, result) + } + + @Suppress("MaxLineLength") + @Test + fun `softDeleteCipher with ciphersService softDeleteCipher success should return DeleteCipherResult success`() = + runTest { + mockkStatic(Cipher::toEncryptedNetworkCipherResponse) + every { + createMockSdkCipher(number = 1).toEncryptedNetworkCipherResponse() + } returns createMockCipher(number = 1) + val fixedInstant = Instant.parse("2021-01-01T00:00:00Z") + val userId = "mockId-1" + val cipherId = "mockId-1" + coEvery { + vaultSdkSource.encryptCipher( + userId = userId, + cipherView = createMockCipherView(number = 1) + .copy( + deletedDate = fixedInstant, + ), + ) + } returns createMockSdkCipher(number = 1).asSuccess() + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { ciphersService.softDeleteCipher(cipherId = cipherId) } returns Unit.asSuccess() + coEvery { vaultDiskSource.deleteCipher(userId, cipherId) } just runs + coEvery { + vaultDiskSource.saveCipher( + userId = userId, + cipher = createMockCipher(number = 1), + ) + } returns Unit + val cipherView = createMockCipherView(number = 1) + mockkStatic(Instant::class) + every { Instant.now() } returns fixedInstant + + val result = vaultRepository.softDeleteCipher( + cipherId = cipherId, + cipherView = cipherView, + ) + + assertEquals(DeleteCipherResult.Success, result) + unmockkStatic(Instant::class) + unmockkStatic(Cipher::toEncryptedNetworkCipherResponse) + } + @Test fun `createSend with encryptSend failure should return CreateSendResult failure`() = runTest { @@ -2297,6 +2361,12 @@ class VaultRepositoryTest { return mockUri } + private fun setupMockInstant(): Instant { + val mockInstant = mockk() + every { Instant.now() } returns Instant.MIN + return mockInstant + } + //endregion Helper functions } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt index e2c258d04..7f473bbfb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt @@ -32,6 +32,18 @@ import org.junit.Test class VaultSdkCipherExtensionsTest { + @Test + fun `toEncryptedNetworkCipherResponse should convert an Sdk Cipher to a cipher`() { + val sdkCipher = createMockSdkCipher(number = 1) + + val result = sdkCipher.toEncryptedNetworkCipherResponse() + + assertEquals( + createMockCipher(number = 1), + result, + ) + } + @Test fun `toEncryptedNetworkCipher should convert an Sdk Cipher to a Network Cipher`() { val sdkCipher = createMockSdkCipher(number = 1) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index ae01d67c7..712489bb5 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.assertTextEquals @@ -23,6 +24,7 @@ import androidx.compose.ui.test.onSiblings import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.core.net.toUri +import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText @@ -156,7 +158,7 @@ class VaultItemScreenTest : BaseComposeTest() { composeTestRule.onNodeWithText("Loading").assertDoesNotExist() mutableStateFlow.update { - it.copy(dialog = VaultItemState.DialogState.Loading) + it.copy(dialog = VaultItemState.DialogState.Loading(R.string.loading.asText())) } composeTestRule @@ -558,21 +560,74 @@ class VaultItemScreenTest : BaseComposeTest() { } @Test - fun `Delete option menu click should send DeleteClick action`() { + fun `menu Delete option click should send show deletion confirmation dialog`() { // Confirm dropdown version of item is absent composeTestRule .onAllNodesWithText("Delete") .filter(hasAnyAncestor(isPopup())) .assertCountEquals(0) // Open the overflow menu - composeTestRule.onNodeWithContentDescription("More").performClick() + composeTestRule + .onNodeWithContentDescription("More") + .performClick() // Click on the delete item in the dropdown composeTestRule .onAllNodesWithText("Delete") .filterToOne(hasAnyAncestor(isPopup())) .performClick() + + composeTestRule + .onNodeWithText("Do you really want to send to the trash?") + .assertIsDisplayed() + } + + @Test + fun `Delete dialog cancel click should hide deletion confirmation menu`() { + // Open the overflow menu + composeTestRule + .onNodeWithContentDescription("More") + .performClick() + // Click on the delete item in the dropdown + composeTestRule + .onAllNodesWithText("Delete") + .filterToOne(hasAnyAncestor(isPopup())) + .performClick() + + composeTestRule + .onNodeWithText("Do you really want to send to the trash?") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Cancel") + .performClick() + + composeTestRule + .onNodeWithText("Do you really want to send to the trash?") + .assertIsNotDisplayed() + } + + @Test + fun `Delete dialog ok click should send ConfirmDeleteClick`() { + // Open the overflow menu + composeTestRule + .onNodeWithContentDescription("More") + .performClick() + // Click on the delete item in the dropdown + composeTestRule + .onAllNodesWithText("Delete") + .filterToOne(hasAnyAncestor(isPopup())) + .performClick() + + composeTestRule + .onNodeWithText("Do you really want to send to the trash?") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Ok") + .performClick() + verify { - viewModel.trySendAction(VaultItemAction.Common.DeleteClick) + viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 0c55a13ac..76ea08ea6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -10,7 +10,9 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.vault.feature.item.util.createCommonContent @@ -97,7 +99,11 @@ class VaultItemViewModelTest : BaseViewModelTest() { @Test fun `on DismissDialogClick should clear the dialog state`() = runTest { - val initialState = DEFAULT_STATE.copy(dialog = VaultItemState.DialogState.Loading) + val initialState = DEFAULT_STATE.copy( + dialog = VaultItemState.DialogState.Loading( + message = R.string.loading.asText(), + ), + ) val viewModel = createViewModel(state = initialState) assertEquals(initialState, viewModel.stateFlow.value) @@ -105,6 +111,65 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(initialState.copy(dialog = null), viewModel.stateFlow.value) } + @Test + @Suppress("MaxLineLength") + fun `ConfirmDeleteClick with DeleteCipherResult Success should should ShowToast and NavigateBack`() = + runTest { + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + val viewModel = createViewModel(state = DEFAULT_STATE) + coEvery { + vaultRepo.softDeleteCipher( + cipherId = VAULT_ITEM_ID, + cipherView = createMockCipherView(number = 1), + ) + } returns DeleteCipherResult.Success + + viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick) + + viewModel.eventFlow.test { + assertEquals( + VaultItemEvent.ShowToast(R.string.item_soft_deleted.asText()), + awaitItem(), + ) + assertEquals( + VaultItemEvent.NavigateBack, + awaitItem(), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `ConfirmDeleteClick with DeleteCipherResult Failure should should Show generic error`() = + runTest { + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + val viewModel = createViewModel(state = DEFAULT_STATE) + coEvery { + vaultRepo.softDeleteCipher( + cipherId = VAULT_ITEM_ID, + cipherView = createMockCipherView(number = 1), + ) + } returns DeleteCipherResult.Error + + viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick) + + assertEquals( + DEFAULT_STATE.copy( + viewState = DEFAULT_VIEW_STATE, + dialog = VaultItemState.DialogState.Generic( + message = R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + @Test fun `on EditClick should do nothing when ViewState is not Content`() = runTest { val initialState = DEFAULT_STATE @@ -175,7 +240,11 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(loginState, awaitItem()) viewModel.trySendAction(VaultItemAction.Common.MasterPasswordSubmit("password")) assertEquals( - loginState.copy(dialog = VaultItemState.DialogState.Loading), + loginState.copy( + dialog = VaultItemState.DialogState.Loading( + message = R.string.loading.asText(), + ), + ), awaitItem(), ) assertEquals( @@ -298,7 +367,9 @@ class VaultItemViewModelTest : BaseViewModelTest() { isVisible = false, ) val loginViewState = VaultItemState.ViewState.Content( - common = createCommonContent(isEmpty = true).copy( + common = createCommonContent( + isEmpty = true, + ).copy( requiresReprompt = false, customFields = listOf(hiddenField), ), @@ -334,18 +405,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } - @Test - fun `on DeleteClick should emit ShowToast`() = runTest { - val viewModel = createViewModel(state = DEFAULT_STATE) - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Common.DeleteClick) - assertEquals( - VaultItemEvent.ShowToast("Not yet implemented.".asText()), - awaitItem(), - ) - } - } - @Test fun `on AttachmentsClick should emit NavigateToAttachments`() = runTest { val viewModel = createViewModel(state = DEFAULT_STATE) @@ -413,7 +472,11 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals(loginState, awaitItem()) viewModel.trySendAction(VaultItemAction.ItemType.Login.CheckForBreachClick) assertEquals( - loginState.copy(dialog = VaultItemState.DialogState.Loading), + loginState.copy( + dialog = VaultItemState.DialogState.Loading( + message = R.string.loading.asText(), + ), + ), awaitItem(), ) assertEquals( @@ -864,6 +927,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { ), ), requiresReprompt = true, + currentCipher = createMockCipherView(number = 1), ) private val DEFAULT_VIEW_STATE: VaultItemState.ViewState.Content = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt index fd4a331ce..79b729349 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt @@ -24,12 +24,12 @@ class CipherViewExtensionsTest { @Test fun `toViewState should transform full CipherView into ViewState Login Content with premium`() { - val viewState = createCipherView(type = CipherType.LOGIN, isEmpty = false) - .toViewState(isPremiumUser = true) + val cipherView = createCipherView(type = CipherType.LOGIN, isEmpty = false) + val viewState = cipherView.toViewState(isPremiumUser = true) assertEquals( VaultItemState.ViewState.Content( - common = createCommonContent(isEmpty = false), + common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView), type = createLoginContent(isEmpty = false), ), viewState, @@ -40,12 +40,12 @@ class CipherViewExtensionsTest { @Test fun `toViewState should transform full CipherView into ViewState Login Content without premium`() { val isPremiumUser = false - val viewState = createCipherView(type = CipherType.LOGIN, isEmpty = false) - .toViewState(isPremiumUser = isPremiumUser) + val cipherView = createCipherView(type = CipherType.LOGIN, isEmpty = false) + val viewState = cipherView.toViewState(isPremiumUser = isPremiumUser) assertEquals( VaultItemState.ViewState.Content( - common = createCommonContent(isEmpty = false), + common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView), type = createLoginContent(isEmpty = false).copy(isPremiumUser = isPremiumUser), ), viewState, @@ -54,12 +54,14 @@ class CipherViewExtensionsTest { @Test fun `toViewState should transform empty CipherView into ViewState Login Content`() { - val viewState = createCipherView(type = CipherType.LOGIN, isEmpty = true) - .toViewState(isPremiumUser = true) + val cipherView = createCipherView(type = CipherType.LOGIN, isEmpty = true) + val viewState = cipherView.toViewState(isPremiumUser = true) assertEquals( VaultItemState.ViewState.Content( - common = createCommonContent(isEmpty = true), + common = createCommonContent(isEmpty = true).copy( + currentCipher = cipherView, + ), type = createLoginContent(isEmpty = true), ), viewState, @@ -68,12 +70,12 @@ class CipherViewExtensionsTest { @Test fun `toViewState should transform full CipherView into ViewState Identity Content`() { - val viewState = createCipherView(type = CipherType.IDENTITY, isEmpty = false) - .toViewState(isPremiumUser = true) + val cipherView = createCipherView(type = CipherType.IDENTITY, isEmpty = false) + val viewState = cipherView.toViewState(isPremiumUser = true) assertEquals( VaultItemState.ViewState.Content( - common = createCommonContent(isEmpty = false), + common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView), type = createIdentityContent(isEmpty = false), ), viewState, @@ -82,12 +84,12 @@ class CipherViewExtensionsTest { @Test fun `toViewState should transform empty CipherView into ViewState Identity Content`() { - val viewState = createCipherView(type = CipherType.IDENTITY, isEmpty = true) - .toViewState(isPremiumUser = true) + val cipherView = createCipherView(type = CipherType.IDENTITY, isEmpty = true) + val viewState = cipherView.toViewState(isPremiumUser = true) assertEquals( VaultItemState.ViewState.Content( - common = createCommonContent(isEmpty = true), + common = createCommonContent(isEmpty = true).copy(currentCipher = cipherView), type = createIdentityContent(isEmpty = true), ), viewState, @@ -97,51 +99,62 @@ class CipherViewExtensionsTest { @Suppress("MaxLineLength") @Test fun `toViewState should transform CipherView with odd naming into ViewState Identity Content`() { - val viewState = createCipherView(type = CipherType.IDENTITY, isEmpty = false) - val result = viewState + val initialCipherView = createCipherView(type = CipherType.IDENTITY, isEmpty = false) + val cipherView = initialCipherView .copy( - identity = viewState.identity?.copy( + identity = initialCipherView.identity?.copy( title = "MX", firstName = null, middleName = "middleName", lastName = null, ), ) - .toViewState(isPremiumUser = true) + val viewState = cipherView.toViewState(isPremiumUser = true) assertEquals( VaultItemState.ViewState.Content( - common = createCommonContent(isEmpty = false), + common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView), type = createIdentityContent( isEmpty = false, identityName = "Mx middleName", ), ), - result, + viewState, ) } @Suppress("MaxLineLength") @Test fun `toViewState should transform CipherView with odd address into ViewState Identity Content`() { - val viewState = createCipherView(type = CipherType.IDENTITY, isEmpty = false) - val result = viewState - .copy( - identity = viewState.identity?.copy( - address1 = null, - address2 = null, - address3 = "address3", - city = null, - state = "state", - postalCode = null, - country = null, - ), - ) - .toViewState(isPremiumUser = true) + val initialCipherView = createCipherView(type = CipherType.IDENTITY, isEmpty = false) + val cipherView = initialCipherView.copy( + identity = initialCipherView.identity?.copy( + address1 = null, + address2 = null, + address3 = "address3", + city = null, + state = "state", + postalCode = null, + country = null, + ), + ) + val result = cipherView.toViewState(isPremiumUser = true) assertEquals( VaultItemState.ViewState.Content( - common = createCommonContent(isEmpty = false), + common = createCommonContent(isEmpty = false).copy( + currentCipher = cipherView.copy( + identity = cipherView.identity?.copy( + address1 = null, + address2 = null, + address3 = "address3", + city = null, + state = "state", + postalCode = null, + country = null, + ), + ), + ), type = createIdentityContent( isEmpty = false, address = """ @@ -156,12 +169,12 @@ class CipherViewExtensionsTest { @Test fun `toViewState should transform full CipherView into ViewState Secure Note Content`() { - val viewState = createCipherView(type = CipherType.SECURE_NOTE, isEmpty = false) - .toViewState(isPremiumUser = true) + val cipherView = createCipherView(type = CipherType.SECURE_NOTE, isEmpty = false) + val viewState = cipherView.toViewState(isPremiumUser = true) assertEquals( VaultItemState.ViewState.Content( - common = createCommonContent(isEmpty = false), + common = createCommonContent(isEmpty = false).copy(currentCipher = cipherView), type = VaultItemState.ViewState.Content.ItemType.SecureNote, ), viewState, @@ -171,11 +184,11 @@ class CipherViewExtensionsTest { @Suppress("MaxLineLength") @Test fun `toViewState should transform empty Secure Note CipherView into ViewState Secure Note Content`() { - val viewState = createCipherView(type = CipherType.SECURE_NOTE, isEmpty = true) - .toViewState(isPremiumUser = true) + val cipherView = createCipherView(type = CipherType.SECURE_NOTE, isEmpty = true) + val viewState = cipherView.toViewState(isPremiumUser = true) val expectedState = VaultItemState.ViewState.Content( - common = createCommonContent(isEmpty = true), + common = createCommonContent(isEmpty = true).copy(currentCipher = cipherView), type = VaultItemState.ViewState.Content.ItemType.SecureNote, )