BIT-639: Restore items from trash (#735)

This commit is contained in:
Ramsey Smith 2024-01-24 09:00:13 -07:00 committed by Álison Fernandes
parent 8a16672b4d
commit 0422d3fdd8
12 changed files with 448 additions and 2 deletions

View file

@ -53,4 +53,12 @@ interface CiphersApi {
suspend fun softDeleteCipher(
@Path("cipherId") cipherId: String,
): Result<Unit>
/**
* Restores a cipher.
*/
@PUT("ciphers/{cipherId}/restore")
suspend fun restoreCipher(
@Path("cipherId") cipherId: String,
): Result<Unit>
}

View file

@ -39,4 +39,9 @@ interface CiphersService {
* Attempt to soft delete a cipher.
*/
suspend fun softDeleteCipher(cipherId: String): Result<Unit>
/**
* Attempt to restore a cipher.
*/
suspend fun restoreCipher(cipherId: String): Result<Unit>
}

View file

@ -50,4 +50,7 @@ class CiphersServiceImpl constructor(
override suspend fun softDeleteCipher(cipherId: String): Result<Unit> =
ciphersApi.softDeleteCipher(cipherId = cipherId)
override suspend fun restoreCipher(cipherId: String): Result<Unit> =
ciphersApi.restoreCipher(cipherId = cipherId)
}

View file

@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
@ -187,6 +188,14 @@ interface VaultRepository : VaultLockManager {
cipherView: CipherView,
): DeleteCipherResult
/**
* Attempt to restore a cipher.
*/
suspend fun restoreCipher(
cipherId: String,
cipherView: CipherView,
): RestoreCipherResult
/**
* Attempt to update a cipher.
*/

View file

@ -44,6 +44,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
@ -486,6 +487,33 @@ class VaultRepositoryImpl(
)
}
override suspend fun restoreCipher(
cipherId: String,
cipherView: CipherView,
): RestoreCipherResult {
val userId = requireNotNull(activeUserId)
return ciphersService
.restoreCipher(cipherId)
.flatMap {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = cipherView.copy(
deletedDate = null,
),
)
}
.onSuccess { cipher ->
vaultDiskSource.saveCipher(
userId = userId,
cipher = cipher.toEncryptedNetworkCipherResponse(),
)
}
.fold(
onSuccess = { RestoreCipherResult.Success },
onFailure = { RestoreCipherResult.Error },
)
}
override suspend fun updateCipher(
cipherId: String,
cipherView: CipherView,

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.vault.repository.model
/**
* Models result of restoring a cipher.
*/
sealed class RestoreCipherResult {
/**
* Cipher restored successfully.
*/
data object Success : RestoreCipherResult()
/**
* Generic error while restoring a cipher.
*/
data object Error : RestoreCipherResult()
}

View file

@ -38,6 +38,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
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.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
@ -68,7 +69,11 @@ fun VaultItemScreen(
val confirmDeleteClickAction = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick) }
}
val confirmRestoreAction = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick) }
}
var pendingDeleteCipher by rememberSaveable { mutableStateOf(false) }
var pendingRestoreCipher by rememberSaveable { mutableStateOf(false) }
EventsEffect(viewModel = viewModel) { event ->
when (event) {
@ -128,6 +133,24 @@ fun VaultItemScreen(
},
)
}
if (pendingRestoreCipher) {
BitwardenTwoButtonDialog(
title = stringResource(id = R.string.restore),
message = stringResource(id = R.string.do_you_really_want_to_restore_cipher),
confirmButtonText = stringResource(id = R.string.ok),
dismissButtonText = stringResource(id = R.string.cancel),
onConfirmClick = {
pendingRestoreCipher = false
confirmRestoreAction()
},
onDismissClick = {
pendingRestoreCipher = false
},
onDismissRequest = {
pendingRestoreCipher = false
},
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
@ -144,6 +167,12 @@ fun VaultItemScreen(
{ viewModel.trySendAction(VaultItemAction.Common.CloseClick) }
},
actions = {
if (state.isCipherDeleted) {
BitwardenTextButton(
label = stringResource(id = R.string.restore),
onClick = { pendingRestoreCipher = true },
)
}
// TODO make action list dependent on item being in an organization BIT-1446
BitwardenOverflowActionItem(
menuItemDataList = persistentListOf(
@ -184,7 +213,7 @@ fun VaultItemScreen(
},
floatingActionButton = {
AnimatedVisibility(
visible = state.viewState is VaultItemState.ViewState.Content,
visible = state.isFabVisible,
enter = scaleIn(),
exit = scaleOut(),
) {

View file

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan
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.RestoreCipherResult
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
@ -102,6 +103,7 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Common.CloneClick -> handleCloneClick()
is VaultItemAction.Common.MoveToOrganizationClick -> handleMoveToOrganizationClick()
is VaultItemAction.Common.ConfirmDeleteClick -> handleConfirmDeleteClick()
is VaultItemAction.Common.ConfirmRestoreClick -> handleConfirmRestoreClick()
}
}
@ -246,6 +248,33 @@ class VaultItemViewModel @Inject constructor(
}
}
private fun handleConfirmRestoreClick() {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Loading(
R.string.restoring.asText(),
),
)
}
onContent { content ->
content
.common
.currentCipher
?.let { cipher ->
viewModelScope.launch {
trySendAction(
VaultItemAction.Internal.RestoreCipherReceive(
result = vaultRepository.restoreCipher(
cipherId = state.vaultItemId,
cipherView = cipher,
),
),
)
}
}
}
}
//endregion Common Handlers
//region Login Type Handlers
@ -414,6 +443,7 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
is VaultItemAction.Internal.VerifyPasswordReceive -> handleVerifyPasswordReceive(action)
is VaultItemAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action)
is VaultItemAction.Internal.RestoreCipherReceive -> handleRestoreCipherReceive(action)
}
}
@ -545,6 +575,26 @@ class VaultItemViewModel @Inject constructor(
}
}
private fun handleRestoreCipherReceive(action: VaultItemAction.Internal.RestoreCipherReceive) {
when (action.result) {
RestoreCipherResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Generic(
message = R.string.generic_error_message.asText(),
),
)
}
}
RestoreCipherResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(VaultItemEvent.ShowToast(message = R.string.item_restored.asText()))
sendEvent(VaultItemEvent.NavigateBack)
}
}
}
//endregion Internal Type Handlers
private inline fun onContent(
@ -594,6 +644,21 @@ data class VaultItemState(
val dialog: DialogState?,
) : Parcelable {
/**
* Whether or not the cipher has been deleted.
*/
val isCipherDeleted: Boolean
get() = (viewState as? ViewState.Content)
?.common
?.currentCipher
?.deletedDate != null
/**
* Whether or not the fab is visible.
*/
val isFabVisible: Boolean
get() = viewState is ViewState.Content && !isCipherDeleted
/**
* Represents the specific view states for the [VaultItemScreen].
*/
@ -900,6 +965,11 @@ sealed class VaultItemAction {
*/
data object ConfirmDeleteClick : Common()
/**
* The user has confirmed to restore the cipher.
*/
data object ConfirmRestoreClick : Common()
/**
* The user has clicked to dismiss the dialog.
*/
@ -1060,5 +1130,12 @@ sealed class VaultItemAction {
data class DeleteCipherReceive(
val result: DeleteCipherResult,
) : Internal()
/**
* Indicates that the restore cipher result has been received.
*/
data class RestoreCipherReceive(
val result: RestoreCipherResult,
) : Internal()
}
}

View file

@ -101,6 +101,14 @@ class CiphersServiceTest : BaseServiceTest() {
result.getOrThrow(),
)
}
@Test
fun `restoreCipher should execute the restoreCipher API`() = runTest {
server.enqueue(MockResponse().setResponseCode(200))
val cipherId = "cipherId"
val result = ciphersService.restoreCipher(cipherId = cipherId)
assertEquals(Unit, result.getOrThrow())
}
}
private const val CREATE_UPDATE_CIPHER_SUCCESS_JSON = """

View file

@ -65,6 +65,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
@ -156,6 +157,8 @@ class VaultRepositoryTest {
@AfterEach
fun tearDown() {
unmockkStatic(Uri::class)
unmockkStatic(Instant::class)
unmockkStatic(Cipher::toEncryptedNetworkCipherResponse)
}
@Test
@ -1680,7 +1683,6 @@ class VaultRepositoryTest {
} 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,
@ -1701,6 +1703,64 @@ class VaultRepositoryTest {
unmockkStatic(Cipher::toEncryptedNetworkCipherResponse)
}
@Suppress("MaxLineLength")
@Test
fun `restoreCipher with ciphersService restoreCipher failure should return RestoreCipherResult Error`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val cipherId = "mockId-1"
coEvery {
ciphersService.restoreCipher(cipherId = cipherId)
} returns Throwable("Fail").asFailure()
val result = vaultRepository.restoreCipher(
cipherId = cipherId,
cipherView = createMockCipherView(number = 1),
)
assertEquals(RestoreCipherResult.Error, result)
}
@Suppress("MaxLineLength")
@Test
fun `restoreCipher with ciphersService restoreCipher success should return RestoreCipherResult 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 = null,
),
)
} returns createMockSdkCipher(number = 1).asSuccess()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery { ciphersService.restoreCipher(cipherId = cipherId) } returns Unit.asSuccess()
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.restoreCipher(
cipherId = cipherId,
cipherView = cipherView,
)
assertEquals(RestoreCipherResult.Success, result)
}
@Test
fun `createSend with encryptSend failure should return CreateSendResult failure`() =
runTest {

View file

@ -26,9 +26,11 @@ 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.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.assertScrollableNodeDoesNotExist
import com.x8bit.bitwarden.ui.util.isProgressBar
import com.x8bit.bitwarden.ui.util.onFirstNodeWithTextAfterScroll
@ -47,6 +49,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.time.Instant
@Suppress("LargeClass")
class VaultItemScreenTest : BaseComposeTest() {
@ -631,6 +634,145 @@ class VaultItemScreenTest : BaseComposeTest() {
}
}
@Test
fun `Restore click should send show restore confirmation dialog`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_IDENTITY_VIEW_STATE
.copy(
common = DEFAULT_COMMON
.copy(
currentCipher = createMockCipherView(1).copy(
deletedDate = Instant.MIN,
),
),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText("Restore")
.performClick()
composeTestRule
.onAllNodesWithText("Do you really want to restore this item?")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Restore")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Test
fun `Restore dialog cancel click should hide restore confirmation menu`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_IDENTITY_VIEW_STATE
.copy(
common = DEFAULT_COMMON
.copy(
currentCipher = createMockCipherView(1).copy(
deletedDate = Instant.MIN,
),
),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText("Restore")
.performClick()
composeTestRule
.onAllNodesWithText("Do you really want to restore this item?")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Restore")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
composeTestRule.assertNoDialogExists()
}
@Test
fun `Restore dialog ok click should close the dialog and send ConfirmRestoreClick`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_IDENTITY_VIEW_STATE
.copy(
common = DEFAULT_COMMON
.copy(
currentCipher = createMockCipherView(1).copy(
deletedDate = Instant.MIN,
),
),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText("Restore")
.performClick()
composeTestRule
.onAllNodesWithText("Do you really want to restore this item?")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Restore")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
composeTestRule.assertNoDialogExists()
verify {
viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick)
}
}
@Test
fun `Attachments option menu click should send AttachmentsClick action`() {
// Confirm dropdown version of item is absent

View file

@ -13,6 +13,7 @@ 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.data.vault.repository.model.RestoreCipherResult
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
@ -170,6 +171,65 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
}
@Test
@Suppress("MaxLineLength")
fun `ConfirmRestoreClick with RestoreCipherResult Success should should ShowToast and NavigateBack`() =
runTest {
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
val viewModel = createViewModel(state = DEFAULT_STATE)
coEvery {
vaultRepo.restoreCipher(
cipherId = VAULT_ITEM_ID,
cipherView = createMockCipherView(number = 1),
)
} returns RestoreCipherResult.Success
viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick)
viewModel.eventFlow.test {
assertEquals(
VaultItemEvent.ShowToast(R.string.item_restored.asText()),
awaitItem(),
)
assertEquals(
VaultItemEvent.NavigateBack,
awaitItem(),
)
}
}
@Test
@Suppress("MaxLineLength")
fun `ConfirmRestoreClick with RestoreCipherResult Failure should should Show generic error`() =
runTest {
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
val viewModel = createViewModel(state = DEFAULT_STATE)
coEvery {
vaultRepo.restoreCipher(
cipherId = VAULT_ITEM_ID,
cipherView = createMockCipherView(number = 1),
)
} returns RestoreCipherResult.Error
viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick)
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