[PM-33561] debt: Wire CipherManager and cipher ViewModel error handlers (#6651)

This commit is contained in:
Patrick Honkonen authored and GitHub committed 2026-03-13 17:08:41 +00:00
1 parent 870f15418b
commit 85c3a1deb8
25 files changed
+1019 -35

No files matched your search

@@ -114,7 +114,10 @@ class Fido2CredentialStoreImpl(
private fun UpdateCipherResult.toCreateCipherResult(): CreateCipherResult =
when (this) {
UpdateCipherResult.Success -> CreateCipherResult.Success
is UpdateCipherResult.Error -> CreateCipherResult.Error(errorMessage, error)
is UpdateCipherResult.Error -> CreateCipherResult.Error(
error = error,
errorMessage = errorMessage,
)
}
/**
@@ -85,10 +85,7 @@ class CipherManagerImpl(
override suspend fun createCipher(cipherView: CipherView): CreateCipherResult {
val userId = activeUserId
?: return CreateCipherResult.Error(
error = NoActiveUserException(),
errorMessage = null,
)
?: return CreateCipherResult.Error(error = NoActiveUserException())
return vaultSdkSource
.encryptCipher(
userId = userId,
@@ -111,7 +108,7 @@ class CipherManagerImpl(
}
}
.fold(
onFailure = { CreateCipherResult.Error(errorMessage = null, error = it) },
onFailure = { CreateCipherResult.Error(error = it) },
onSuccess = {
reviewPromptManager.registerAddCipherAction()
it
@@ -124,7 +121,7 @@ class CipherManagerImpl(
collectionIds: List<String>,
): CreateCipherResult {
val userId = activeUserId
?: return CreateCipherResult.Error(errorMessage = null, error = NoActiveUserException())
?: return CreateCipherResult.Error(error = NoActiveUserException())
return vaultSdkSource
.encryptCipher(
userId = userId,
@@ -157,7 +154,7 @@ class CipherManagerImpl(
}
}
.fold(
onFailure = { CreateCipherResult.Error(errorMessage = null, error = it) },
onFailure = { CreateCipherResult.Error(error = it) },
onSuccess = {
reviewPromptManager.registerAddCipherAction()
it
@@ -169,7 +166,8 @@ class CipherManagerImpl(
cipherId: String,
cipherView: CipherView,
): ArchiveCipherResult {
val userId = activeUserId ?: return ArchiveCipherResult.Error(NoActiveUserException())
val userId = activeUserId
?: return ArchiveCipherResult.Error(error = NoActiveUserException())
return ciphersService
.archiveCipher(cipherId = cipherId)
.flatMap { response ->
@@ -204,7 +202,8 @@ class CipherManagerImpl(
cipherId: String,
cipherView: CipherView,
): UnarchiveCipherResult {
val userId = activeUserId ?: return UnarchiveCipherResult.Error(NoActiveUserException())
val userId = activeUserId
?: return UnarchiveCipherResult.Error(error = NoActiveUserException())
return ciphersService
.unarchiveCipher(cipherId = cipherId)
.flatMap { response ->
@@ -368,7 +367,7 @@ class CipherManagerImpl(
cipherView: CipherView,
): UpdateCipherResult {
val userId = activeUserId
?: return UpdateCipherResult.Error(errorMessage = null, error = NoActiveUserException())
?: return UpdateCipherResult.Error(error = NoActiveUserException())
return vaultSdkSource
.encryptCipher(
userId = userId,
@@ -399,7 +398,7 @@ class CipherManagerImpl(
}
}
.fold(
onFailure = { UpdateCipherResult.Error(errorMessage = null, error = it) },
onFailure = { UpdateCipherResult.Error(error = it) },
onSuccess = { it },
)
}
@@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of archiving a cipher.
*/
@@ -16,6 +18,6 @@ sealed class ArchiveCipherResult {
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
val errorMessage: String? = error.userFriendlyMessage,
) : ArchiveCipherResult()
}
@@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of creating a cipher.
*/
@@ -14,5 +16,8 @@ sealed class CreateCipherResult {
* Generic error while creating cipher. The optional [errorMessage] may be displayed directly in
* the UI when present.
*/
data class Error(val errorMessage: String?, val error: Throwable?) : CreateCipherResult()
data class Error(
val error: Throwable?,
val errorMessage: String? = error?.userFriendlyMessage,
) : CreateCipherResult()
}
@@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of deleting an attachment from a cipher.
*/
@@ -16,6 +18,6 @@ sealed class DeleteAttachmentResult {
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
val errorMessage: String? = error.userFriendlyMessage,
) : DeleteAttachmentResult()
}
@@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of deleting a cipher.
*/
@@ -16,6 +18,6 @@ sealed class DeleteCipherResult {
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
val errorMessage: String? = error.userFriendlyMessage,
) : DeleteCipherResult()
}
@@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of restoring a cipher.
*/
@@ -16,6 +18,6 @@ sealed class RestoreCipherResult {
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
val errorMessage: String? = error.userFriendlyMessage,
) : RestoreCipherResult()
}
@@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of sharing a cipher.
*/
@@ -15,6 +17,6 @@ sealed class ShareCipherResult {
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
val errorMessage: String? = error.userFriendlyMessage,
) : ShareCipherResult()
}
@@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of unarchiving a cipher.
*/
@@ -16,6 +18,6 @@ sealed class UnarchiveCipherResult {
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
val errorMessage: String? = error.userFriendlyMessage,
) : UnarchiveCipherResult()
}
@@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of updating a cipher.
*/
@@ -14,5 +16,8 @@ sealed class UpdateCipherResult {
* Generic error while updating cipher. The optional [errorMessage] may be displayed directly in
* the UI when present.
*/
data class Error(val errorMessage: String?, val error: Throwable?) : UpdateCipherResult()
data class Error(
val error: Throwable?,
val errorMessage: String? = error?.userFriendlyMessage,
) : UpdateCipherResult()
}
@@ -676,7 +676,8 @@ class SearchViewModel @Inject constructor(
it.copy(
dialogState = SearchState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_archive_selected_item.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.unable_to_archive_selected_item.asText(),
throwable = result.error,
),
)
@@ -697,7 +698,8 @@ class SearchViewModel @Inject constructor(
it.copy(
dialogState = SearchState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.unable_to_unarchive_selected_item.asText(),
throwable = result.error,
),
)
@@ -1821,7 +1821,8 @@ class VaultAddEditViewModel @Inject constructor(
is ArchiveCipherResult.Error -> {
showDialog(
dialogState = VaultAddEditState.DialogState.Generic(
message = BitwardenString.unable_to_archive_selected_item.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.unable_to_archive_selected_item.asText(),
error = result.error,
),
)
@@ -1845,7 +1846,8 @@ class VaultAddEditViewModel @Inject constructor(
is UnarchiveCipherResult.Error -> {
showDialog(
dialogState = VaultAddEditState.DialogState.Generic(
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.unable_to_unarchive_selected_item.asText(),
error = result.error,
),
)
@@ -1873,7 +1875,8 @@ class VaultAddEditViewModel @Inject constructor(
is DeleteCipherResult.Error -> {
showDialog(
dialogState = VaultAddEditState.DialogState.Generic(
message = BitwardenString.generic_error_message.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.generic_error_message.asText(),
error = result.error,
),
)
@@ -298,7 +298,10 @@ class AttachmentsViewModel @Inject constructor(
it.copy(
dialogState = AttachmentsState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
message = result
.errorMessage
?.asText()
?: BitwardenString.generic_error_message.asText(),
throwable = result.error,
),
)
@@ -1183,7 +1183,8 @@ class VaultItemViewModel @Inject constructor(
is DeleteCipherResult.Error -> {
updateDialogState(
VaultItemState.DialogState.Generic(
message = BitwardenString.generic_error_message.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.generic_error_message.asText(),
error = result.error,
),
)
@@ -1211,7 +1212,8 @@ class VaultItemViewModel @Inject constructor(
is RestoreCipherResult.Error -> {
updateDialogState(
VaultItemState.DialogState.Generic(
message = BitwardenString.generic_error_message.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.generic_error_message.asText(),
error = result.error,
),
)
@@ -1291,7 +1293,8 @@ class VaultItemViewModel @Inject constructor(
is ArchiveCipherResult.Error -> {
updateDialogState(
dialog = VaultItemState.DialogState.Generic(
message = BitwardenString.unable_to_archive_selected_item.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.unable_to_archive_selected_item.asText(),
error = result.error,
),
)
@@ -1315,7 +1318,8 @@ class VaultItemViewModel @Inject constructor(
is UnarchiveCipherResult.Error -> {
updateDialogState(
dialog = VaultItemState.DialogState.Generic(
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.unable_to_unarchive_selected_item.asText(),
error = result.error,
),
)
@@ -1723,7 +1723,8 @@ class VaultItemListingViewModel @Inject constructor(
it.copy(
dialogState = VaultItemListingState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_archive_selected_item.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.unable_to_archive_selected_item.asText(),
throwable = result.error,
),
)
@@ -1750,7 +1751,8 @@ class VaultItemListingViewModel @Inject constructor(
it.copy(
dialogState = VaultItemListingState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.unable_to_unarchive_selected_item.asText(),
throwable = result.error,
),
)
@@ -161,7 +161,10 @@ class VaultMoveToOrganizationViewModel @Inject constructor(
mutableStateFlow.update {
it.copy(
dialogState = VaultMoveToOrganizationState.DialogState.Error(
message = BitwardenString.generic_error_message.asText(),
message = result
.errorMessage
?.asText()
?: BitwardenString.generic_error_message.asText(),
throwable = result.error,
),
)
@@ -1018,7 +1018,8 @@ class VaultViewModel @Inject constructor(
it.copy(
dialog = VaultState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_archive_selected_item.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.unable_to_archive_selected_item.asText(),
error = result.error,
),
)
@@ -1039,7 +1040,8 @@ class VaultViewModel @Inject constructor(
it.copy(
dialog = VaultState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.unable_to_unarchive_selected_item.asText(),
error = result.error,
),
)
@@ -8,6 +8,7 @@ import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.data.manager.model.DownloadResult
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.model.ArchiveCipherResponseJson
import com.bitwarden.network.model.AttachmentJsonRequest
import com.bitwarden.network.model.CreateCipherInOrganizationJsonRequest
@@ -2933,6 +2934,114 @@ class CipherManagerTest {
}
}
@Suppress("MaxLineLength")
@Test
fun `hardDeleteCipher with CookieRedirectException should return DeleteCipherResult Error with errorMessage`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val cipherId = "mockId-1"
val error = CookieRedirectException("test.host")
coEvery {
ciphersService.hardDeleteCipher(cipherId = cipherId)
} returns error.asFailure()
val result = cipherManager.hardDeleteCipher(cipherId)
assertEquals(
DeleteCipherResult.Error(
errorMessage = "Your request was interrupted because " +
"the app needed to re-authenticate. Please try again.",
error = error,
),
result,
)
}
@Suppress("MaxLineLength")
@Test
fun `softDeleteCipher with CookieRedirectException should return DeleteCipherResult Error with errorMessage`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = MOCK_USER_STATE.activeUserId
val cipherId = "mockId-1"
val cipherView = createMockCipherView(number = 1)
val encryptionContext = createMockEncryptionContext(number = 1)
val error = CookieRedirectException("test.host")
coEvery {
vaultSdkSource.encryptCipher(userId = userId, cipherView = cipherView)
} returns encryptionContext.asSuccess()
coEvery {
ciphersService.softDeleteCipher(cipherId = cipherId)
} returns error.asFailure()
val result = cipherManager.softDeleteCipher(
cipherId = cipherId,
cipherView = cipherView,
)
assertEquals(
DeleteCipherResult.Error(
errorMessage = "Your request was interrupted because " +
"the app needed to re-authenticate. Please try again.",
error = error,
),
result,
)
}
@Suppress("MaxLineLength")
@Test
fun `restoreCipher with CookieRedirectException should return RestoreCipherResult Error with errorMessage`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val cipherId = "mockId-1"
val cipherView = createMockCipherView(number = 1)
val error = CookieRedirectException("test.host")
coEvery {
ciphersService.restoreCipher(cipherId = cipherId)
} returns error.asFailure()
val result = cipherManager.restoreCipher(
cipherId = cipherId,
cipherView = cipherView,
)
assertEquals(
RestoreCipherResult.Error(
errorMessage = "Your request was interrupted because " +
"the app needed to re-authenticate. Please try again.",
error = error,
),
result,
)
}
@Suppress("MaxLineLength")
@Test
fun `archiveCipher with CookieRedirectException should return ArchiveCipherResult Error with errorMessage`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val cipherId = "mockId-1"
val error = CookieRedirectException("test.host")
coEvery {
ciphersService.archiveCipher(cipherId = cipherId)
} returns error.asFailure()
val result = cipherManager.archiveCipher(
cipherId = cipherId,
cipherView = createMockCipherView(number = 1),
)
assertEquals(
ArchiveCipherResult.Error(
errorMessage = "Your request was interrupted because " +
"the app needed to re-authenticate. Please try again.",
error = error,
),
result,
)
}
private fun setupMockUri(
url: String,
queryParams: Map<String, String> = emptyMap(),
@@ -398,6 +398,46 @@ class SearchViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `ArchiveClick with ArchiveCipherResult error with errorMessage should display that message`() =
runTest {
val cipherView = createMockCipherView(number = 1, clock = clock)
val viewModel = createViewModel(initialState = null)
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Oh dang.")
coEvery {
vaultRepository.archiveCipher(
cipherId = "mockId-1",
cipherView = cipherView,
)
} returns ArchiveCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.trySendAction(
SearchAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.ArchiveClick(
cipherId = "mockId-1",
),
),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SearchState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = errorMessage.asText(),
throwable = error,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `UnarchiveClick with UnarchiveCipherResult Success should emit a ShowSnackbar event`() =
runTest {
@@ -456,6 +496,46 @@ class SearchViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `UnarchiveClick with UnarchiveCipherResult error with errorMessage should display that message`() =
runTest {
val cipherView = createMockCipherView(number = 1, clock = clock)
val viewModel = createViewModel(initialState = null)
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Oh dang.")
coEvery {
vaultRepository.unarchiveCipher(
cipherId = "mockId-1",
cipherView = cipherView,
)
} returns UnarchiveCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.trySendAction(
SearchAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.UnarchiveClick(
cipherId = "mockId-1",
),
),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SearchState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = errorMessage.asText(),
throwable = error,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `AutofillItemClick should call emitAccessibilitySelection`() = runTest {
val cipherView = setupForAutofill(
@@ -735,6 +735,80 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `ConfirmDeleteClick with DeleteCipherResult error with errorMessage should display that message`() =
runTest {
val cipherListView = createMockCipherListView(number = 1)
val cipherView = createMockCipherView(number = 1)
val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID)
val initState = createVaultAddItemState(vaultAddEditType = vaultAddEditType)
mutableVaultDataFlow.value = DataState.Loaded(
data = createVaultData(cipherListView = cipherListView),
)
val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = initState,
vaultAddEditType = vaultAddEditType,
vaultItemCipherType = VaultItemCipherType.LOGIN,
),
)
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Oh dang.")
coEvery {
vaultRepository.softDeleteCipher(
cipherId = "mockId-1",
cipherView = cipherView,
)
} returns DeleteCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.trySendAction(VaultAddEditAction.Common.ConfirmDeleteClick)
assertEquals(
createVaultAddItemState(
vaultAddEditType = vaultAddEditType,
dialogState = VaultAddEditState.DialogState.Generic(
message = errorMessage.asText(),
error = error,
),
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
originalCipher = createMockCipherView(number = 1),
notes = "mockNotes-1",
customFieldData = listOf(
VaultAddEditState.Custom.HiddenField(
itemId = "testId",
name = "mockName-1",
value = "mockValue-1",
),
),
),
typeContentViewState = createLoginTypeContentViewState(
username = "mockUsername-1",
password = "mockPassword-1",
uri = listOf(
UriItem(
id = "testId",
uri = "www.mockuri1.com",
match = UriMatchType.HOST,
checksum = "mockUriChecksum-1",
),
),
totpCode = "mockTotp-1",
canViewPassword = true,
fido2CredentialCreationDateTime = null,
)
.copy(totp = "mockTotp-1"),
),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `in add mode, SaveClick should show dialog, remove it once an item is saved, and emit NavigateBack`() =
@@ -2476,6 +2550,96 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `ArchiveClick with ArchiveCipherResult error with errorMessage should display that message`() =
runTest {
mutableUserStateFlow.update {
it?.copy(
accounts = it.accounts.map { account ->
account.copy(isPremium = true)
},
)
}
val cipherListView =
createMockCipherListView(number = 1, isArchived = false)
val cipherView =
createMockCipherView(number = 1, isArchived = false)
val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID)
val initState = createVaultAddItemState(
vaultAddEditType = vaultAddEditType,
commonContentViewState = createCommonContentViewState(
originalCipher = cipherView,
),
hasPremium = true,
)
mutableVaultDataFlow.value = DataState.Loaded(
data = createVaultData(cipherListView = cipherListView),
)
val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = initState,
vaultAddEditType = vaultAddEditType,
vaultItemCipherType = VaultItemCipherType.LOGIN,
),
)
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Oh dang.")
coEvery {
vaultRepository.archiveCipher(
cipherId = "mockId-1",
cipherView = cipherView,
)
} returns ArchiveCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.trySendAction(VaultAddEditAction.Common.ArchiveClick)
assertEquals(
createVaultAddItemState(
hasPremium = true,
vaultAddEditType = vaultAddEditType,
dialogState = VaultAddEditState.DialogState.Generic(
message = errorMessage.asText(),
error = error,
),
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
originalCipher = createMockCipherView(number = 1),
notes = "mockNotes-1",
customFieldData = listOf(
VaultAddEditState.Custom.HiddenField(
itemId = "testId",
name = "mockName-1",
value = "mockValue-1",
),
),
),
typeContentViewState = createLoginTypeContentViewState(
username = "mockUsername-1",
password = "mockPassword-1",
uri = listOf(
UriItem(
id = "testId",
uri = "www.mockuri1.com",
match = UriMatchType.HOST,
checksum = "mockUriChecksum-1",
),
),
totpCode = "mockTotp-1",
canViewPassword = true,
fido2CredentialCreationDateTime = null,
)
.copy(totp = "mockTotp-1"),
),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `UnarchiveClick with UnarchiveCipherResult Success should send snackbar event and NavigateBack`() =
@@ -2588,6 +2752,95 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `UnarchiveClick with UnarchiveCipherResult error with errorMessage should display that message`() =
runTest {
mutableUserStateFlow.update {
it?.copy(
accounts = it.accounts.map { account ->
account.copy(isPremium = true)
},
)
}
val cipherListView =
createMockCipherListView(number = 1, isArchived = false)
val cipherView =
createMockCipherView(number = 1, isArchived = false)
val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID)
val initState = createVaultAddItemState(
vaultAddEditType = vaultAddEditType,
commonContentViewState = createCommonContentViewState(
originalCipher = cipherView,
),
hasPremium = true,
)
mutableVaultDataFlow.value = DataState.Loaded(
data = createVaultData(cipherListView = cipherListView),
)
val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = initState,
vaultAddEditType = vaultAddEditType,
vaultItemCipherType = VaultItemCipherType.LOGIN,
),
)
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Oh dang.")
coEvery {
vaultRepository.unarchiveCipher(
cipherId = "mockId-1",
cipherView = cipherView,
)
} returns UnarchiveCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.trySendAction(VaultAddEditAction.Common.UnarchiveClick)
assertEquals(
createVaultAddItemState(
hasPremium = true,
vaultAddEditType = vaultAddEditType,
dialogState = VaultAddEditState.DialogState.Generic(
message = errorMessage.asText(),
error = error,
),
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
originalCipher = createMockCipherView(number = 1),
notes = "mockNotes-1",
customFieldData = listOf(
VaultAddEditState.Custom.HiddenField(
itemId = "testId",
name = "mockName-1",
value = "mockValue-1",
),
),
),
typeContentViewState = createLoginTypeContentViewState(
username = "mockUsername-1",
password = "mockPassword-1",
uri = listOf(
UriItem(
id = "testId",
uri = "www.mockuri1.com",
match = UriMatchType.HOST,
checksum = "mockUriChecksum-1",
),
),
totpCode = "mockTotp-1",
canViewPassword = true,
fido2CredentialCreationDateTime = null,
),
),
viewModel.stateFlow.value,
)
}
@Nested
inner class VaultAddEditLoginTypeItemActions {
private lateinit var viewModel: VaultAddEditViewModel
@@ -480,6 +480,55 @@ class AttachmentsViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `DeleteClick with deleteCipherAttachment error with errorMessage should display that message`() =
runTest {
val cipherId = "mockId-1"
val attachmentId = "mockId-1"
val cipherView = createMockCipherView(number = 1)
val initialState = DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS,
)
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Fail")
coEvery {
vaultRepository.deleteCipherAttachment(
cipherId = cipherId,
attachmentId = attachmentId,
cipherView = cipherView,
)
} returns DeleteAttachmentResult.Error(
errorMessage = errorMessage,
error = error,
)
mutableVaultItemStateFlow.value = DataState.Loaded(cipherView)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
viewModel.trySendAction(AttachmentsAction.DeleteClick(attachmentId))
assertEquals(
initialState.copy(
dialogState = AttachmentsState.DialogState.Loading(
message = BitwardenString.deleting.asText(),
),
),
awaitItem(),
)
assertEquals(
initialState.copy(
dialogState = AttachmentsState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = errorMessage.asText(),
throwable = error,
),
),
awaitItem(),
)
}
}
@Test
fun `DeleteClick with deleteCipherAttachment success should emit ShowSnackbar`() = runTest {
val cipherId = "mockId-1"
@@ -355,6 +355,59 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `ArchiveClick with ArchiveCipherResult error with errorMessage should display that message`() =
runTest {
val cipherView = createMockCipherView(number = 1, isArchived = false)
every {
cipherView.toViewState(
previousState = null,
isPremiumUser = true,
totpCodeItemData = null,
canDelete = true,
canRestore = false,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = false,
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
} returns DEFAULT_VIEW_STATE
mutableVaultItemFlow.value = DataState.Loaded(data = cipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
mutableFoldersStateFlow.value = DataState.Loaded(emptyList())
val viewModel = createViewModel(state = null)
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Oh dang.")
coEvery {
vaultRepo.archiveCipher(
cipherId = "mockId-1",
cipherView = cipherView,
)
} returns ArchiveCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.trySendAction(VaultItemAction.Common.ArchiveClick)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE,
dialog = VaultItemState.DialogState.Generic(
message = errorMessage.asText(),
error = error,
),
),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `UnarchiveClick with UnarchiveCipherResult Success should send snackbar event and NavigateBack`() =
@@ -457,6 +510,69 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `UnarchiveClick with UnarchiveCipherResult error with errorMessage should display that message`() =
runTest {
val cipherView = createMockCipherView(number = 1, isArchived = true)
every {
cipherView.toViewState(
previousState = null,
isPremiumUser = true,
totpCodeItemData = null,
canDelete = true,
canRestore = false,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = false,
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
} returns DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON.copy(
archived = true,
currentCipher = cipherView,
),
)
mutableVaultItemFlow.value = DataState.Loaded(data = cipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
mutableFoldersStateFlow.value = DataState.Loaded(emptyList())
val viewModel = createViewModel(state = null)
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Oh dang.")
coEvery {
vaultRepo.unarchiveCipher(
cipherId = "mockId-1",
cipherView = cipherView,
)
} returns UnarchiveCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.trySendAction(VaultItemAction.Common.UnarchiveClick)
assertEquals(
DEFAULT_STATE.copy(
dialog = VaultItemState.DialogState.Generic(
message = errorMessage.asText(),
error = error,
),
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON.copy(
archived = true,
currentCipher = cipherView,
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `DeleteClick should update state`() =
runTest {
@@ -631,6 +747,57 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `ConfirmDeleteClick with DeleteCipherResult error with errorMessage should display that message`() =
runTest {
every {
mockCipherView.toViewState(
previousState = null,
isPremiumUser = true,
totpCodeItemData = null,
canDelete = true,
canRestore = false,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = false,
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
} returns DEFAULT_VIEW_STATE
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
mutableFoldersStateFlow.value = DataState.Loaded(emptyList())
val viewModel = createViewModel(state = DEFAULT_STATE)
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Oh dang.")
coEvery {
vaultRepo.softDeleteCipher(
cipherId = VAULT_ITEM_ID,
cipherView = createMockCipherView(number = 1),
)
} returns DeleteCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE,
dialog = VaultItemState.DialogState.Generic(
message = errorMessage.asText(),
error = error,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `ConfirmDeleteClick with deleted cipher should should invoke hardDeleteCipher`() =
runTest {
@@ -830,6 +997,57 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `ConfirmRestoreClick with RestoreCipherResult error with errorMessage should display that message`() {
every {
mockCipherView.toViewState(
previousState = null,
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
canDelete = true,
canRestore = false,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = false,
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
} returns DEFAULT_VIEW_STATE
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
mutableFoldersStateFlow.value = DataState.Loaded(emptyList())
val viewModel = createViewModel(state = DEFAULT_STATE)
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Fail")
coEvery {
vaultRepo.restoreCipher(
cipherId = VAULT_ITEM_ID,
cipherView = createMockCipherView(number = 1),
)
} returns RestoreCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE,
dialog = VaultItemState.DialogState.Generic(
message = errorMessage.asText(),
error = error,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on EditClick should navigate password`() = runTest {
val loginViewState = createViewState()
@@ -1155,6 +1155,46 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `ArchiveClick with ArchiveCipherResult error with errorMessage should display that message`() =
runTest {
val cipherView = createMockCipherView(number = 1, clock = clock)
val viewModel = createVaultItemListingViewModel()
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Oh dang.")
coEvery {
vaultRepository.archiveCipher(
cipherId = "mockId-1",
cipherView = cipherView,
)
} returns ArchiveCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.trySendAction(
VaultItemListingsAction.OverflowOptionClick(
action = ListingItemOverflowAction.VaultAction.ArchiveClick(
cipherId = "mockId-1",
),
),
)
assertEquals(
createVaultItemListingState(
dialogState = VaultItemListingState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = errorMessage.asText(),
throwable = error,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `UnarchiveClick with UnarchiveCipherResult Success should emit a ShowSnackbar event`() =
runTest {
@@ -1215,6 +1255,46 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `UnarchiveClick with UnarchiveCipherResult error with errorMessage should display that message`() =
runTest {
val cipherView = createMockCipherView(number = 1, clock = clock)
val viewModel = createVaultItemListingViewModel()
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Oh dang.")
coEvery {
vaultRepository.unarchiveCipher(
cipherId = "mockId-1",
cipherView = cipherView,
)
} returns UnarchiveCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.trySendAction(
VaultItemListingsAction.OverflowOptionClick(
action = ListingItemOverflowAction.VaultAction.UnarchiveClick(
cipherId = "mockId-1",
),
),
)
assertEquals(
createVaultItemListingState(
dialogState = VaultItemListingState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = errorMessage.asText(),
throwable = error,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `MasterPasswordRepromptSubmit for a request Error should show a generic error dialog`() =
runTest {
@@ -385,6 +385,78 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `MoveClick with shareCipher error with errorMessage should show error dialog with that message`() =
runTest {
val viewModel = createViewModel()
mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS))
mutableVaultItemFlow.tryEmit(
value = DataState.Loaded(createMockCipherView(number = 1)),
)
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Fail")
coEvery {
vaultRepository.shareCipher(
cipherId = "mockCipherId",
organizationId = "mockOrganizationId-1",
cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"),
)
} returns ShareCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "mockOrganizationId-1",
cipherToMove = createMockCipherView(number = 1),
),
),
awaitItem(),
)
viewModel.trySendAction(VaultMoveToOrganizationAction.MoveClick)
assertEquals(
initialState.copy(
dialogState = VaultMoveToOrganizationState.DialogState.Loading(
message = BitwardenString.saving.asText(),
),
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "mockOrganizationId-1",
cipherToMove = createMockCipherView(number = 1),
),
),
awaitItem(),
)
assertEquals(
initialState.copy(
dialogState = VaultMoveToOrganizationState.DialogState.Error(
message = errorMessage.asText(),
throwable = error,
),
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "mockOrganizationId-1",
cipherToMove = createMockCipherView(number = 1),
),
),
awaitItem(),
)
}
coVerify {
vaultRepository.shareCipher(
cipherId = "mockCipherId",
organizationId = "mockOrganizationId-1",
cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"),
)
}
}
@Test
fun `MoveClick with shareCipher success should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
@@ -750,6 +750,46 @@ class VaultViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `ArchiveClick with ArchiveCipherResult error with errorMessage should display that message`() =
runTest {
val cipherView = createMockCipherView(number = 1, clock = clock)
val viewModel = createViewModel()
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Oh dang.")
coEvery {
vaultRepository.archiveCipher(
cipherId = "mockId-1",
cipherView = cipherView,
)
} returns ArchiveCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.trySendAction(
VaultAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.ArchiveClick(
cipherId = "mockId-1",
),
),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VaultState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = errorMessage.asText(),
error = error,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `UnarchiveClick with UnarchiveCipherResult Success should emit a ShowSnackbar event`() =
runTest {
@@ -808,6 +848,46 @@ class VaultViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `UnarchiveClick with UnarchiveCipherResult error with errorMessage should display that message`() =
runTest {
val cipherView = createMockCipherView(number = 1, clock = clock)
val viewModel = createViewModel()
val errorMessage = "You do not have permission to edit this."
val error = Throwable("Oh dang.")
coEvery {
vaultRepository.unarchiveCipher(
cipherId = "mockId-1",
cipherView = cipherView,
)
} returns UnarchiveCipherResult.Error(
errorMessage = errorMessage,
error = error,
)
viewModel.trySendAction(
VaultAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.UnarchiveClick(
cipherId = "mockId-1",
),
),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VaultState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = errorMessage.asText(),
error = error,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on LockAccountClick should call lockVault for the given account`() {
val accountUserId = "userId"