mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-639: Restore items from trash (#735)
This commit is contained in:
parent
8a16672b4d
commit
0422d3fdd8
12 changed files with 448 additions and 2 deletions
|
@ -53,4 +53,12 @@ interface CiphersApi {
|
||||||
suspend fun softDeleteCipher(
|
suspend fun softDeleteCipher(
|
||||||
@Path("cipherId") cipherId: String,
|
@Path("cipherId") cipherId: String,
|
||||||
): Result<Unit>
|
): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores a cipher.
|
||||||
|
*/
|
||||||
|
@PUT("ciphers/{cipherId}/restore")
|
||||||
|
suspend fun restoreCipher(
|
||||||
|
@Path("cipherId") cipherId: String,
|
||||||
|
): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,4 +39,9 @@ interface CiphersService {
|
||||||
* Attempt to soft delete a cipher.
|
* Attempt to soft delete a cipher.
|
||||||
*/
|
*/
|
||||||
suspend fun softDeleteCipher(cipherId: String): Result<Unit>
|
suspend fun softDeleteCipher(cipherId: String): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to restore a cipher.
|
||||||
|
*/
|
||||||
|
suspend fun restoreCipher(cipherId: String): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,4 +50,7 @@ class CiphersServiceImpl constructor(
|
||||||
|
|
||||||
override suspend fun softDeleteCipher(cipherId: String): Result<Unit> =
|
override suspend fun softDeleteCipher(cipherId: String): Result<Unit> =
|
||||||
ciphersApi.softDeleteCipher(cipherId = cipherId)
|
ciphersApi.softDeleteCipher(cipherId = cipherId)
|
||||||
|
|
||||||
|
override suspend fun restoreCipher(cipherId: String): Result<Unit> =
|
||||||
|
ciphersApi.restoreCipher(cipherId = cipherId)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.DeleteSendResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
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.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.SendData
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
|
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
|
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
|
||||||
|
@ -187,6 +188,14 @@ interface VaultRepository : VaultLockManager {
|
||||||
cipherView: CipherView,
|
cipherView: CipherView,
|
||||||
): DeleteCipherResult
|
): DeleteCipherResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to restore a cipher.
|
||||||
|
*/
|
||||||
|
suspend fun restoreCipher(
|
||||||
|
cipherId: String,
|
||||||
|
cipherView: CipherView,
|
||||||
|
): RestoreCipherResult
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to update a cipher.
|
* Attempt to update a cipher.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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.DeleteSendResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
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.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.SendData
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
|
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
|
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(
|
override suspend fun updateCipher(
|
||||||
cipherId: String,
|
cipherId: String,
|
||||||
cipherView: CipherView,
|
cipherView: CipherView,
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
|
@ -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.BitwardenMasterPasswordDialog
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
|
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.BitwardenTopAppBar
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
|
||||||
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
|
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
|
||||||
|
@ -68,7 +69,11 @@ fun VaultItemScreen(
|
||||||
val confirmDeleteClickAction = remember(viewModel) {
|
val confirmDeleteClickAction = remember(viewModel) {
|
||||||
{ viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick) }
|
{ viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick) }
|
||||||
}
|
}
|
||||||
|
val confirmRestoreAction = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick) }
|
||||||
|
}
|
||||||
var pendingDeleteCipher by rememberSaveable { mutableStateOf(false) }
|
var pendingDeleteCipher by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var pendingRestoreCipher by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
EventsEffect(viewModel = viewModel) { event ->
|
EventsEffect(viewModel = viewModel) { event ->
|
||||||
when (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())
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
BitwardenScaffold(
|
BitwardenScaffold(
|
||||||
|
@ -144,6 +167,12 @@ fun VaultItemScreen(
|
||||||
{ viewModel.trySendAction(VaultItemAction.Common.CloseClick) }
|
{ viewModel.trySendAction(VaultItemAction.Common.CloseClick) }
|
||||||
},
|
},
|
||||||
actions = {
|
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
|
// TODO make action list dependent on item being in an organization BIT-1446
|
||||||
BitwardenOverflowActionItem(
|
BitwardenOverflowActionItem(
|
||||||
menuItemDataList = persistentListOf(
|
menuItemDataList = persistentListOf(
|
||||||
|
@ -184,7 +213,7 @@ fun VaultItemScreen(
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = state.viewState is VaultItemState.ViewState.Content,
|
visible = state.isFabVisible,
|
||||||
enter = scaleIn(),
|
enter = scaleIn(),
|
||||||
exit = scaleOut(),
|
exit = scaleOut(),
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -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.platform.repository.model.DataState
|
||||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
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.DeleteCipherResult
|
||||||
|
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.VerifyPasswordResult
|
import com.x8bit.bitwarden.data.vault.repository.model.VerifyPasswordResult
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
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.Text
|
||||||
|
@ -102,6 +103,7 @@ class VaultItemViewModel @Inject constructor(
|
||||||
is VaultItemAction.Common.CloneClick -> handleCloneClick()
|
is VaultItemAction.Common.CloneClick -> handleCloneClick()
|
||||||
is VaultItemAction.Common.MoveToOrganizationClick -> handleMoveToOrganizationClick()
|
is VaultItemAction.Common.MoveToOrganizationClick -> handleMoveToOrganizationClick()
|
||||||
is VaultItemAction.Common.ConfirmDeleteClick -> handleConfirmDeleteClick()
|
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
|
//endregion Common Handlers
|
||||||
|
|
||||||
//region Login Type Handlers
|
//region Login Type Handlers
|
||||||
|
@ -414,6 +443,7 @@ class VaultItemViewModel @Inject constructor(
|
||||||
is VaultItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
|
is VaultItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
|
||||||
is VaultItemAction.Internal.VerifyPasswordReceive -> handleVerifyPasswordReceive(action)
|
is VaultItemAction.Internal.VerifyPasswordReceive -> handleVerifyPasswordReceive(action)
|
||||||
is VaultItemAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(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
|
//endregion Internal Type Handlers
|
||||||
|
|
||||||
private inline fun onContent(
|
private inline fun onContent(
|
||||||
|
@ -594,6 +644,21 @@ data class VaultItemState(
|
||||||
val dialog: DialogState?,
|
val dialog: DialogState?,
|
||||||
) : Parcelable {
|
) : 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].
|
* Represents the specific view states for the [VaultItemScreen].
|
||||||
*/
|
*/
|
||||||
|
@ -900,6 +965,11 @@ sealed class VaultItemAction {
|
||||||
*/
|
*/
|
||||||
data object ConfirmDeleteClick : Common()
|
data object ConfirmDeleteClick : Common()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user has confirmed to restore the cipher.
|
||||||
|
*/
|
||||||
|
data object ConfirmRestoreClick : Common()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The user has clicked to dismiss the dialog.
|
* The user has clicked to dismiss the dialog.
|
||||||
*/
|
*/
|
||||||
|
@ -1060,5 +1130,12 @@ sealed class VaultItemAction {
|
||||||
data class DeleteCipherReceive(
|
data class DeleteCipherReceive(
|
||||||
val result: DeleteCipherResult,
|
val result: DeleteCipherResult,
|
||||||
) : Internal()
|
) : Internal()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates that the restore cipher result has been received.
|
||||||
|
*/
|
||||||
|
data class RestoreCipherReceive(
|
||||||
|
val result: RestoreCipherResult,
|
||||||
|
) : Internal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -101,6 +101,14 @@ class CiphersServiceTest : BaseServiceTest() {
|
||||||
result.getOrThrow(),
|
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 = """
|
private const val CREATE_UPDATE_CIPHER_SUCCESS_JSON = """
|
||||||
|
|
|
@ -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.DeleteSendResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
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.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.SendData
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
|
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
|
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
|
||||||
|
@ -156,6 +157,8 @@ class VaultRepositoryTest {
|
||||||
@AfterEach
|
@AfterEach
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
unmockkStatic(Uri::class)
|
unmockkStatic(Uri::class)
|
||||||
|
unmockkStatic(Instant::class)
|
||||||
|
unmockkStatic(Cipher::toEncryptedNetworkCipherResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -1680,7 +1683,6 @@ class VaultRepositoryTest {
|
||||||
} returns createMockSdkCipher(number = 1).asSuccess()
|
} returns createMockSdkCipher(number = 1).asSuccess()
|
||||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||||
coEvery { ciphersService.softDeleteCipher(cipherId = cipherId) } returns Unit.asSuccess()
|
coEvery { ciphersService.softDeleteCipher(cipherId = cipherId) } returns Unit.asSuccess()
|
||||||
coEvery { vaultDiskSource.deleteCipher(userId, cipherId) } just runs
|
|
||||||
coEvery {
|
coEvery {
|
||||||
vaultDiskSource.saveCipher(
|
vaultDiskSource.saveCipher(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
|
@ -1701,6 +1703,64 @@ class VaultRepositoryTest {
|
||||||
unmockkStatic(Cipher::toEncryptedNetworkCipherResponse)
|
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
|
@Test
|
||||||
fun `createSend with encryptSend failure should return CreateSendResult failure`() =
|
fun `createSend with encryptSend failure should return CreateSendResult failure`() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|
|
@ -26,9 +26,11 @@ import androidx.compose.ui.test.performTextInput
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.x8bit.bitwarden.R
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
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.BaseComposeTest
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
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.assertScrollableNodeDoesNotExist
|
||||||
import com.x8bit.bitwarden.ui.util.isProgressBar
|
import com.x8bit.bitwarden.ui.util.isProgressBar
|
||||||
import com.x8bit.bitwarden.ui.util.onFirstNodeWithTextAfterScroll
|
import com.x8bit.bitwarden.ui.util.onFirstNodeWithTextAfterScroll
|
||||||
|
@ -47,6 +49,7 @@ import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
@Suppress("LargeClass")
|
@Suppress("LargeClass")
|
||||||
class VaultItemScreenTest : BaseComposeTest() {
|
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
|
@Test
|
||||||
fun `Attachments option menu click should send AttachmentsClick action`() {
|
fun `Attachments option menu click should send AttachmentsClick action`() {
|
||||||
// Confirm dropdown version of item is absent
|
// Confirm dropdown version of item is absent
|
||||||
|
|
|
@ -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.datasource.sdk.model.createMockCipherView
|
||||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
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.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.BaseViewModelTest
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
import com.x8bit.bitwarden.ui.vault.feature.item.util.createCommonContent
|
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
|
@Test
|
||||||
fun `on EditClick should do nothing when ViewState is not Content`() = runTest {
|
fun `on EditClick should do nothing when ViewState is not Content`() = runTest {
|
||||||
val initialState = DEFAULT_STATE
|
val initialState = DEFAULT_STATE
|
||||||
|
|
Loading…
Reference in a new issue