BIT-1408: Delete cipher (#691)

This commit is contained in:
Ramsey Smith 2024-01-21 16:06:14 -07:00 committed by Álison Fernandes
parent c9d7a48598
commit d706a20211
15 changed files with 510 additions and 102 deletions

View file

@ -39,10 +39,18 @@ interface CiphersApi {
): Result<SyncResponseJson.Cipher>
/**
* Deletes a cipher.
* Hard deletes a cipher.
*/
@DELETE("ciphers/{cipherId}")
suspend fun deleteCipher(
suspend fun hardDeleteCipher(
@Path("cipherId") cipherId: String,
): Result<Unit>
/**
* Soft deletes a cipher.
*/
@PUT("ciphers/{cipherId}/delete")
suspend fun softDeleteCipher(
@Path("cipherId") cipherId: String,
): Result<Unit>
}

View file

@ -31,7 +31,12 @@ interface CiphersService {
): Result<SyncResponseJson.Cipher>
/**
* Attempt to delete a cipher.
* Attempt to hard delete a cipher.
*/
suspend fun deleteCipher(cipherId: String): Result<Unit>
suspend fun hardDeleteCipher(cipherId: String): Result<Unit>
/**
* Attempt to soft delete a cipher.
*/
suspend fun softDeleteCipher(cipherId: String): Result<Unit>
}

View file

@ -45,6 +45,9 @@ class CiphersServiceImpl constructor(
body = body,
)
override suspend fun deleteCipher(cipherId: String): Result<Unit> =
ciphersApi.deleteCipher(cipherId = cipherId)
override suspend fun hardDeleteCipher(cipherId: String): Result<Unit> =
ciphersApi.hardDeleteCipher(cipherId = cipherId)
override suspend fun softDeleteCipher(cipherId: String): Result<Unit> =
ciphersApi.softDeleteCipher(cipherId = cipherId)
}

View file

@ -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.

View file

@ -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,

View file

@ -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<Attachment>.toNetworkAttachmentList(): List<SyncResponseJson.Cipher.Attachment> =
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.

View file

@ -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 -> {

View file

@ -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<Custom>,
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()
}
}

View file

@ -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() },

View file

@ -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())
}

View file

@ -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<Instant>()
every { Instant.now() } returns Instant.MIN
return mockInstant
}
//endregion Helper functions
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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<CipherView> {
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<CipherView> {
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 =

View file

@ -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,
)