BIT-502: Save the updated ciphers from the edit screen (#371)

This commit is contained in:
David Perez 2023-12-12 10:26:34 -06:00 committed by Álison Fernandes
parent 65b9005cbe
commit f4db50b700
8 changed files with 843 additions and 98 deletions

View file

@ -1,6 +1,8 @@
package com.x8bit.bitwarden.data.platform.repository.util
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.transformWhile
/**
* Maps the data inside a [DataState] with the given [transform].
@ -14,3 +16,11 @@ inline fun <T : Any?, R : Any?> DataState<T>.map(
is DataState.Error -> DataState.Error(error, data?.let(transform))
is DataState.NoNetwork -> DataState.NoNetwork(data?.let(transform))
}
/**
* Emits all values of a [DataState] [Flow] until it emits a [DataState.Loaded].
*/
fun <T : Any?> Flow<DataState<T>>.takeUntilLoaded(): Flow<DataState<T>> = transformWhile {
emit(it)
it !is DataState.Loaded
}

View file

@ -4,19 +4,27 @@ import android.os.Parcelable
import androidx.annotation.StringRes
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
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.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.vault.feature.additem.util.toViewState
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toCipherView
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
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
@ -55,6 +63,19 @@ class VaultAddItemViewModel @Inject constructor(
init {
stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
when (val vaultAddEditType = state.vaultAddEditType) {
VaultAddEditType.AddItem -> Unit
is VaultAddEditType.EditItem -> {
vaultRepository
.getVaultItemStateFlow(vaultAddEditType.vaultItemId)
// We'll stop getting updates as soon as we get some loaded data.
.takeUntilLoaded()
.map { VaultAddItemAction.Internal.VaultDataReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
}
}
override fun handleAction(action: VaultAddItemAction) {
@ -83,9 +104,21 @@ class VaultAddItemViewModel @Inject constructor(
handleAddSecureNoteTypeAction(action)
}
is VaultAddItemAction.Internal -> handleInternalActions(action)
}
}
private fun handleInternalActions(action: VaultAddItemAction.Internal) {
when (action) {
is VaultAddItemAction.Internal.CreateCipherResultReceive -> {
handleCreateCipherResultReceive(action)
}
is VaultAddItemAction.Internal.UpdateCipherResultReceive -> {
handleUpdateCipherResultReceive(action)
}
is VaultAddItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
}
}
@ -115,13 +148,20 @@ class VaultAddItemViewModel @Inject constructor(
}
viewModelScope.launch {
sendAction(
action = VaultAddItemAction.Internal.CreateCipherResultReceive(
createCipherResult = vaultRepository.createCipher(
when (val vaultAddEditType = state.vaultAddEditType) {
VaultAddEditType.AddItem -> {
val result = vaultRepository.createCipher(cipherView = content.toCipherView())
sendAction(VaultAddItemAction.Internal.CreateCipherResultReceive(result))
}
is VaultAddEditType.EditItem -> {
val result = vaultRepository.updateCipher(
cipherId = vaultAddEditType.vaultItemId,
cipherView = content.toCipherView(),
),
),
)
)
sendAction(VaultAddItemAction.Internal.UpdateCipherResultReceive(result))
}
}
}
}
@ -533,6 +573,80 @@ class VaultAddItemViewModel @Inject constructor(
}
}
private fun handleUpdateCipherResultReceive(
action: VaultAddItemAction.Internal.UpdateCipherResultReceive,
) {
mutableStateFlow.update { it.copy(dialog = null) }
when (action.updateCipherResult) {
is UpdateCipherResult.Error -> {
// TODO Display error dialog BIT-501
sendEvent(VaultAddItemEvent.ShowToast(message = "Save Item Failure"))
}
is UpdateCipherResult.Success -> {
sendEvent(VaultAddItemEvent.NavigateBack)
}
}
}
private fun handleVaultDataReceive(action: VaultAddItemAction.Internal.VaultDataReceive) {
when (val vaultDataState = action.vaultDataState) {
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
viewState = VaultAddItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
viewState = vaultDataState
.data
?.toViewState()
?: VaultAddItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
DataState.Loading -> {
mutableStateFlow.update {
it.copy(viewState = VaultAddItemState.ViewState.Loading)
}
}
is DataState.NoNetwork -> {
mutableStateFlow.update {
it.copy(
viewState = VaultAddItemState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
)
}
}
is DataState.Pending -> {
mutableStateFlow.update {
it.copy(
viewState = vaultDataState
.data
?.toViewState()
?: VaultAddItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
}
}
//endregion Internal Type Handlers
//region Utility Functions
@ -638,6 +752,14 @@ data class VaultAddItemState(
*/
@Parcelize
sealed class Content : ViewState() {
/**
* The original cipher from the vault that the user is editing.
*
* This is only present when editing a pre-existing cipher.
*/
@IgnoredOnParcel
abstract val originalCipher: CipherView?
/**
* Represents the resource ID for the display string. This is an abstract property
* that must be overridden by each subclass to provide the appropriate string resource
@ -680,6 +802,8 @@ data class VaultAddItemState(
*/
@Parcelize
data class Login(
@IgnoredOnParcel
override val originalCipher: CipherView? = null,
override val name: String = "",
val username: String = "",
val password: String = "",
@ -706,6 +830,8 @@ data class VaultAddItemState(
*/
@Parcelize
data class Card(
@IgnoredOnParcel
override val originalCipher: CipherView? = null,
override val name: String = "",
override val masterPasswordReprompt: Boolean = false,
override val ownership: String = DEFAULT_OWNERSHIP,
@ -719,6 +845,8 @@ data class VaultAddItemState(
*/
@Parcelize
data class Identity(
@IgnoredOnParcel
override val originalCipher: CipherView? = null,
override val name: String = "",
override val masterPasswordReprompt: Boolean = false,
override val ownership: String = DEFAULT_OWNERSHIP,
@ -737,6 +865,8 @@ data class VaultAddItemState(
*/
@Parcelize
data class SecureNotes(
@IgnoredOnParcel
override val originalCipher: CipherView? = null,
override val name: String = "",
val folderName: Text = DEFAULT_FOLDER,
val favorite: Boolean = false,
@ -1006,6 +1136,12 @@ sealed class VaultAddItemAction {
* Models actions that the [VaultAddItemViewModel] itself might send.
*/
sealed class Internal : VaultAddItemAction() {
/**
* Indicates that the vault item data has been received.
*/
data class VaultDataReceive(
val vaultDataState: DataState<CipherView?>,
) : Internal()
/**
* Indicates a result for creating a cipher has been received.
@ -1013,5 +1149,12 @@ sealed class VaultAddItemAction {
data class CreateCipherResultReceive(
val createCipherResult: CreateCipherResult,
) : Internal()
/**
* Indicates a result for updating a cipher has been received.
*/
data class UpdateCipherResultReceive(
val updateCipherResult: UpdateCipherResult,
) : Internal()
}
}

View file

@ -0,0 +1,60 @@
package com.x8bit.bitwarden.ui.vault.feature.additem.util
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState
/**
* Transforms [CipherView] into [VaultAddItemState.ViewState].
*/
fun CipherView.toViewState(): VaultAddItemState.ViewState =
when (type) {
CipherType.LOGIN -> {
val loginView = requireNotNull(this.login)
VaultAddItemState.ViewState.Content.Login(
originalCipher = this,
name = this.name,
username = loginView.username.orEmpty(),
password = loginView.password.orEmpty(),
uri = loginView.uris?.firstOrNull()?.uri.orEmpty(),
favorite = this.favorite,
masterPasswordReprompt = this.reprompt == CipherRepromptType.PASSWORD,
notes = this.notes.orEmpty(),
// TODO: Update these properties to pull folder from data layer (BIT-501)
folderName = this.folderId?.asText() ?: R.string.folder_none.asText(),
availableFolders = emptyList(),
// TODO: Update this property to pull owner from data layer (BIT-501)
ownership = "",
// TODO: Update this property to pull available owners from data layer (BIT-501)
availableOwners = emptyList(),
)
}
CipherType.SECURE_NOTE -> {
VaultAddItemState.ViewState.Content.SecureNotes(
originalCipher = this,
name = this.name,
favorite = this.favorite,
masterPasswordReprompt = this.reprompt == CipherRepromptType.PASSWORD,
notes = this.notes.orEmpty(),
// TODO: Update these properties to pull folder from data layer (BIT-501)
folderName = this.folderId?.asText() ?: R.string.folder_none.asText(),
availableFolders = emptyList(),
// TODO: Update this property to pull owner from data layer (BIT-501)
ownership = "",
// TODO: Update this property to pull available owners from data layer (BIT-501)
availableOwners = emptyList(),
)
}
CipherType.CARD -> VaultAddItemState.ViewState.Error(
message = "Not yet implemented.".asText(),
)
CipherType.IDENTITY -> VaultAddItemState.ViewState.Error(
message = "Not yet implemented.".asText(),
)
}

View file

@ -99,53 +99,53 @@ fun VaultAddItemState.ViewState.Content.toCipherView(): CipherView =
*/
private fun VaultAddItemState.ViewState.Content.Login.toLoginCipherView(): CipherView =
CipherView(
id = null,
// TODO use real organization id BIT-780
organizationId = null,
// TODO use real folder id BIT-528
folderId = null,
collectionIds = emptyList(),
key = null,
name = name,
notes = notes,
// Pulled from original cipher when editing, otherwise uses defaults
id = this.originalCipher?.id,
collectionIds = this.originalCipher?.collectionIds.orEmpty(),
key = this.originalCipher?.key,
edit = this.originalCipher?.edit ?: true,
viewPassword = this.originalCipher?.viewPassword ?: true,
localData = this.originalCipher?.localData,
attachments = this.originalCipher?.attachments,
organizationUseTotp = this.originalCipher?.organizationUseTotp ?: false,
passwordHistory = this.originalCipher?.passwordHistory,
creationDate = this.originalCipher?.creationDate ?: Instant.now(),
deletedDate = this.originalCipher?.deletedDate,
revisionDate = this.originalCipher?.revisionDate ?: Instant.now(),
// Type specific section
type = CipherType.LOGIN,
login = LoginView(
username = username,
password = password,
passwordRevisionDate = null,
username = this.username,
password = this.password,
passwordRevisionDate = this.originalCipher?.login?.passwordRevisionDate,
uris = listOf(
// TODO Implement URI list (BIT-1094)
LoginUriView(
uri = uri,
// TODO implement uri settings in BIT-1094
uri = this.uri,
// TODO Implement URI settings in (BIT-1094)
match = UriMatchType.DOMAIN,
),
),
// TODO implement totp in BIT-1066
totp = null,
autofillOnPageLoad = false,
totp = this.originalCipher?.login?.totp,
autofillOnPageLoad = this.originalCipher?.login?.autofillOnPageLoad,
),
identity = null,
card = null,
secureNote = null,
favorite = favorite,
reprompt = if (masterPasswordReprompt) {
CipherRepromptType.PASSWORD
} else {
CipherRepromptType.NONE
},
organizationUseTotp = false,
edit = true,
viewPassword = true,
localData = null,
attachments = null,
// TODO implement custom fields BIT-529
// Fields we always grab from the UI
name = this.name,
notes = this.notes,
favorite = this.favorite,
// TODO Use real folder ID (BIT-528)
folderId = this.originalCipher?.folderId,
// TODO Use real organization ID (BIT-780)
organizationId = this.originalCipher?.organizationId,
reprompt = this.toCipherRepromptType(),
// TODO Implement custom fields (BIT-529)
fields = null,
passwordHistory = null,
creationDate = Instant.now(),
deletedDate = null,
// This is a throw away value.
// The SDK will eventually remove revisionDate via encryption.
revisionDate = Instant.now(),
)
/**
@ -153,39 +153,38 @@ private fun VaultAddItemState.ViewState.Content.Login.toLoginCipherView(): Ciphe
*/
private fun VaultAddItemState.ViewState.Content.SecureNotes.toSecureNotesCipherView(): CipherView =
CipherView(
id = null,
// TODO use real organization id BIT-780
organizationId = null,
// TODO use real folder id BIT-528
folderId = null,
collectionIds = emptyList(),
key = null,
name = name,
notes = notes,
// Pulled from original cipher when editing, otherwise uses defaults
id = this.originalCipher?.id,
collectionIds = this.originalCipher?.collectionIds.orEmpty(),
key = this.originalCipher?.key,
edit = this.originalCipher?.edit ?: true,
viewPassword = this.originalCipher?.viewPassword ?: true,
localData = this.originalCipher?.localData,
attachments = this.originalCipher?.attachments,
organizationUseTotp = this.originalCipher?.organizationUseTotp ?: false,
passwordHistory = this.originalCipher?.passwordHistory,
creationDate = this.originalCipher?.creationDate ?: Instant.now(),
deletedDate = this.originalCipher?.deletedDate,
revisionDate = this.originalCipher?.revisionDate ?: Instant.now(),
// Type specific section
type = CipherType.SECURE_NOTE,
secureNote = SecureNoteView(SecureNoteType.GENERIC),
secureNote = SecureNoteView(type = SecureNoteType.GENERIC),
login = null,
identity = null,
card = null,
favorite = favorite,
reprompt = if (masterPasswordReprompt) {
CipherRepromptType.PASSWORD
} else {
CipherRepromptType.NONE
},
organizationUseTotp = false,
edit = true,
viewPassword = true,
localData = null,
attachments = null,
// TODO implement custom fields BIT-529
// Fields we always grab from the UI
name = this.name,
notes = this.notes,
favorite = this.favorite,
// TODO Use real folder ID (BIT-528)
folderId = this.originalCipher?.folderId,
// TODO Use real organization ID (BIT-780)
organizationId = this.originalCipher?.organizationId,
reprompt = this.toCipherRepromptType(),
// TODO Implement custom fields (BIT-529)
fields = null,
passwordHistory = null,
creationDate = Instant.now(),
deletedDate = null,
// This is a throw away value.
// The SDK will eventually remove revisionDate via encryption.
revisionDate = Instant.now(),
)
/**
@ -199,3 +198,10 @@ private fun VaultAddItemState.ViewState.Content.Identity.toIdentityCipherView():
*/
private fun VaultAddItemState.ViewState.Content.Card.toCardCipherView(): CipherView =
TODO("create Card CipherView BIT-668")
private fun VaultAddItemState.ViewState.Content.toCipherRepromptType(): CipherRepromptType =
if (this.masterPasswordReprompt) {
CipherRepromptType.PASSWORD
} else {
CipherRepromptType.NONE
}

View file

@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.platform.repository.util
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class DataStateExtensionsTest {
@Test
fun `takeUtilLoaded should complete after a Loaded state is emitted`() = runTest {
val mutableStateFlow = MutableStateFlow<DataState<Unit>>(DataState.Loading)
mutableStateFlow
.takeUntilLoaded()
.test {
assertEquals(DataState.Loading, awaitItem())
mutableStateFlow.value = DataState.NoNetwork(Unit)
assertEquals(DataState.NoNetwork(Unit), awaitItem())
mutableStateFlow.value = DataState.Loaded(Unit)
assertEquals(DataState.Loaded(Unit), awaitItem())
awaitComplete()
}
}
}

View file

@ -2,16 +2,27 @@ package com.x8bit.bitwarden.ui.vault.feature.additem
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.R
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.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.additem.util.toViewState
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
@ -24,7 +35,20 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
state = initialState,
vaultAddEditType = VaultAddEditType.AddItem,
)
private val vaultRepository: VaultRepository = mockk()
private val mutableVaultItemFlow = MutableStateFlow<DataState<CipherView?>>(DataState.Loading)
private val vaultRepository: VaultRepository = mockk {
every { getVaultItemStateFlow(DEFAULT_EDIT_ITEM_ID) } returns mutableVaultItemFlow
}
@BeforeEach
fun setup() {
mockkStatic(CIPHER_VIEW_EXTENSIONS_PATH)
}
@AfterEach
fun tearDown() {
unmockkStatic(CIPHER_VIEW_EXTENSIONS_PATH)
}
@Test
fun `initial state should be correct when state is null`() = runTest {
@ -50,6 +74,9 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
),
)
assertEquals(initState, viewModel.stateFlow.value)
verify(exactly = 0) {
vaultRepository.getVaultItemStateFlow(DEFAULT_EDIT_ITEM_ID)
}
}
@Test
@ -62,7 +89,13 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
vaultAddEditType = vaultAddEditType,
),
)
assertEquals(initState, viewModel.stateFlow.value)
assertEquals(
initState.copy(viewState = VaultAddItemState.ViewState.Loading),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
vaultRepository.getVaultItemStateFlow(DEFAULT_EDIT_ITEM_ID)
}
}
@Test
@ -75,38 +108,44 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
}
@Test
fun `SaveClick should show dialog, and remove it once an item is saved`() = runTest {
val stateWithDialog = createVaultAddLoginItemState(
name = "tester",
dialogState = VaultAddItemState.DialogState.Loading(
R.string.saving.asText(),
),
)
fun `in add mode, SaveClick should show dialog, and remove it once an item is saved`() =
runTest {
val stateWithDialog = createVaultAddLoginItemState(
name = "tester",
dialogState = VaultAddItemState.DialogState.Loading(
R.string.saving.asText(),
),
)
val stateWithName = createVaultAddLoginItemState(
name = "tester",
)
val stateWithName = createVaultAddLoginItemState(
name = "tester",
)
val viewModel = createAddVaultItemViewModel(
createSavedStateHandleWithState(
state = stateWithName,
vaultAddEditType = VaultAddEditType.AddItem,
),
)
val viewModel = createAddVaultItemViewModel(
createSavedStateHandleWithState(
state = stateWithName,
vaultAddEditType = VaultAddEditType.AddItem,
),
)
coEvery {
vaultRepository.createCipher(any())
} returns CreateCipherResult.Success
viewModel.stateFlow.test {
viewModel.actionChannel.trySend(VaultAddItemAction.SaveClick)
assertEquals(stateWithName, awaitItem())
assertEquals(stateWithDialog, awaitItem())
assertEquals(stateWithName, awaitItem())
coEvery {
vaultRepository.createCipher(any())
} returns CreateCipherResult.Success
viewModel.stateFlow.test {
viewModel.actionChannel.trySend(VaultAddItemAction.SaveClick)
assertEquals(stateWithName, awaitItem())
assertEquals(stateWithDialog, awaitItem())
assertEquals(stateWithName, awaitItem())
}
coVerify(exactly = 1) {
vaultRepository.createCipher(any())
}
}
}
@Test
fun `SaveClick should update value to loading`() = runTest {
fun `in add mode, SaveClick should update value to loading`() = runTest {
val stateWithName = createVaultAddLoginItemState(
name = "tester",
)
@ -128,7 +167,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
}
@Test
fun `SaveClick createCipher error should emit ShowToast`() = runTest {
fun `in add mode, SaveClick createCipher error should emit ShowToast`() = runTest {
val stateWithName = createVaultAddLoginItemState(
name = "tester",
)
@ -149,6 +188,82 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `in edit mode, SaveClick should show dialog, and remove it once an item is saved`() =
runTest {
val cipherView = mockk<CipherView>()
val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID)
val stateWithDialog = createVaultAddLoginItemState(
vaultAddEditType = vaultAddEditType,
name = "tester",
dialogState = VaultAddItemState.DialogState.Loading(
R.string.saving.asText(),
),
)
val stateWithName = createVaultAddLoginItemState(
vaultAddEditType = vaultAddEditType,
name = "tester",
)
every { cipherView.toViewState() } returns stateWithName.viewState
mutableVaultItemFlow.value = DataState.Loaded(cipherView)
val viewModel = createAddVaultItemViewModel(
createSavedStateHandleWithState(
state = stateWithName,
vaultAddEditType = vaultAddEditType,
),
)
coEvery {
vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any())
} returns UpdateCipherResult.Success
viewModel.stateFlow.test {
assertEquals(stateWithName, awaitItem())
viewModel.actionChannel.trySend(VaultAddItemAction.SaveClick)
assertEquals(stateWithDialog, awaitItem())
assertEquals(stateWithName, awaitItem())
}
coVerify(exactly = 1) {
cipherView.toViewState()
vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any())
}
}
@Test
fun `in edit mode, SaveClick createCipher error should emit ShowToast`() = runTest {
val cipherView = mockk<CipherView>()
val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID)
val stateWithName = createVaultAddLoginItemState(
vaultAddEditType = vaultAddEditType,
name = "tester",
)
every { cipherView.toViewState() } returns stateWithName.viewState
coEvery {
vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any())
} returns UpdateCipherResult.Error
mutableVaultItemFlow.value = DataState.Loaded(cipherView)
val viewModel = createAddVaultItemViewModel(
createSavedStateHandleWithState(
state = stateWithName,
vaultAddEditType = vaultAddEditType,
),
)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultAddItemAction.SaveClick)
assertEquals(VaultAddItemEvent.ShowToast("Save Item Failure"), awaitItem())
}
coVerify(exactly = 1) {
vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any())
}
}
@Test
fun `Saving item with an empty name field will cause a dialog to show up`() = runTest {
val stateWithNoName = createVaultAddSecureNotesItemState(name = "")
@ -712,4 +827,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
)
}
private const val CIPHER_VIEW_EXTENSIONS_PATH: String =
"com.x8bit.bitwarden.ui.vault.feature.additem.util.CipherViewExtensionsKt"
private const val DEFAULT_EDIT_ITEM_ID: String = "edit_item_id"

View file

@ -0,0 +1,215 @@
package com.x8bit.bitwarden.ui.vault.feature.additem.util
import com.bitwarden.core.CardView
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.FieldType
import com.bitwarden.core.FieldView
import com.bitwarden.core.IdentityView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
import com.bitwarden.core.PasswordHistoryView
import com.bitwarden.core.SecureNoteType
import com.bitwarden.core.SecureNoteView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.Instant
class CipherViewExtensionsTest {
@Test
fun `toViewState should create a Card ViewState`() {
val cipherView = DEFAULT_CARD_CIPHER_VIEW
val result = cipherView.toViewState()
assertEquals(
VaultAddItemState.ViewState.Error(message = "Not yet implemented.".asText()),
result,
)
}
@Test
fun `toViewState should create a Identity ViewState`() {
val cipherView = DEFAULT_IDENTITY_CIPHER_VIEW
val result = cipherView.toViewState()
assertEquals(
VaultAddItemState.ViewState.Error(message = "Not yet implemented.".asText()),
result,
)
}
@Test
fun `toViewState should create a Login ViewState`() {
val cipherView = DEFAULT_LOGIN_CIPHER_VIEW
val result = cipherView.toViewState()
assertEquals(
VaultAddItemState.ViewState.Content.Login(
originalCipher = cipherView,
name = "cipher",
username = "username",
password = "password",
uri = "www.example.com",
folderName = R.string.folder_none.asText(),
favorite = false,
masterPasswordReprompt = true,
notes = "Lots of notes",
ownership = "",
availableFolders = emptyList(),
availableOwners = emptyList(),
),
result,
)
}
@Test
fun `toViewState should create a Secure Notes ViewState`() {
val cipherView = DEFAULT_SECURE_NOTES_CIPHER_VIEW
val result = cipherView.toViewState()
assertEquals(
VaultAddItemState.ViewState.Content.SecureNotes(
originalCipher = cipherView,
name = "cipher",
folderName = R.string.folder_none.asText(),
favorite = false,
masterPasswordReprompt = true,
notes = "Lots of notes",
ownership = "",
availableFolders = emptyList(),
availableOwners = emptyList(),
),
result,
)
}
}
private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView(
id = "id1234",
organizationId = null,
folderId = null,
collectionIds = emptyList(),
key = null,
name = "cipher",
notes = "Lots of notes",
type = CipherType.LOGIN,
login = null,
identity = null,
card = null,
secureNote = null,
favorite = false,
reprompt = CipherRepromptType.PASSWORD,
organizationUseTotp = false,
edit = false,
viewPassword = false,
localData = null,
attachments = null,
fields = listOf(
FieldView(
name = "text",
value = "value",
type = FieldType.TEXT,
linkedId = null,
),
FieldView(
name = "hidden",
value = "value",
type = FieldType.HIDDEN,
linkedId = null,
),
FieldView(
name = "boolean",
value = "true",
type = FieldType.BOOLEAN,
linkedId = null,
),
FieldView(
name = "linked username",
value = null,
type = FieldType.LINKED,
linkedId = 100U,
),
FieldView(
name = "linked password",
value = null,
type = FieldType.LINKED,
linkedId = 101U,
),
),
passwordHistory = listOf(
PasswordHistoryView(
password = "old_password",
lastUsedDate = Instant.ofEpochSecond(1_000L),
),
),
creationDate = Instant.ofEpochSecond(1_000L),
deletedDate = null,
revisionDate = Instant.ofEpochSecond(1_000L),
)
private val DEFAULT_CARD_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy(
type = CipherType.CARD,
card = CardView(
cardholderName = "Bit Warden",
expMonth = "04",
expYear = "2030",
code = "123",
brand = "Visa",
number = "4012888888881881",
),
)
private val DEFAULT_IDENTITY_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy(
type = CipherType.IDENTITY,
identity = IdentityView(
title = "Dr.",
firstName = "John",
lastName = "Smith",
middleName = "Richard",
address1 = null,
address2 = null,
address3 = null,
city = "Minneapolis",
state = "MN",
postalCode = null,
country = "USA",
company = "Bitwarden",
email = "placeholde@email.com",
phone = "555-555-5555",
ssn = null,
username = "Dr. JSR",
passportNumber = null,
licenseNumber = null,
),
)
private val DEFAULT_LOGIN_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy(
type = CipherType.LOGIN,
login = LoginView(
username = "username",
password = "password",
passwordRevisionDate = Instant.ofEpochSecond(1_000L),
uris = listOf(
LoginUriView(
uri = "www.example.com",
match = null,
),
),
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
autofillOnPageLoad = false,
),
)
private val DEFAULT_SECURE_NOTES_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy(
type = CipherType.SECURE_NOTE,
secureNote = SecureNoteView(type = SecureNoteType.GENERIC),
)

View file

@ -3,8 +3,11 @@ package com.x8bit.bitwarden.ui.vault.feature.vault.util
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.FieldType
import com.bitwarden.core.FieldView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
import com.bitwarden.core.PasswordHistoryView
import com.bitwarden.core.SecureNoteType
import com.bitwarden.core.SecureNoteView
import com.bitwarden.core.UriMatchType
@ -144,7 +147,7 @@ class VaultDataExtensionsTest {
),
),
totp = null,
autofillOnPageLoad = false,
autofillOnPageLoad = null,
),
identity = null,
card = null,
@ -166,6 +169,57 @@ class VaultDataExtensionsTest {
)
}
@Test
fun `toCipherView should transform Login ItemType to CipherView with original cipher`() {
val cipherView = DEFAULT_LOGIN_CIPHER_VIEW
val loginItemType = VaultAddItemState.ViewState.Content.Login(
originalCipher = cipherView,
name = "mockName-1",
username = "mockUsername-1",
password = "mockPassword-1",
uri = "mockUri-1",
folderName = "mockFolder-1".asText(),
favorite = true,
masterPasswordReprompt = false,
notes = "mockNotes-1",
ownership = "mockOwnership-1",
)
val result = loginItemType.toCipherView()
assertEquals(
@Suppress("MaxLineLength")
cipherView.copy(
name = "mockName-1",
notes = "mockNotes-1",
type = CipherType.LOGIN,
login = LoginView(
username = "mockUsername-1",
password = "mockPassword-1",
passwordRevisionDate = Instant.ofEpochSecond(1_000L),
uris = listOf(
LoginUriView(
uri = "mockUri-1",
match = UriMatchType.DOMAIN,
),
),
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
autofillOnPageLoad = false,
),
favorite = true,
reprompt = CipherRepromptType.NONE,
fields = null,
passwordHistory = listOf(
PasswordHistoryView(
password = "old_password",
lastUsedDate = Instant.ofEpochSecond(1_000L),
),
),
),
result,
)
}
@Test
fun `toCipherView should transform SecureNotes ItemType to CipherView`() {
mockkStatic(Instant::class)
@ -211,4 +265,117 @@ class VaultDataExtensionsTest {
result,
)
}
@Test
fun `toCipherView should transform SecureNotes ItemType to CipherView with original cipher`() {
val cipherView = DEFAULT_SECURE_NOTES_CIPHER_VIEW
val secureNotesItemType = VaultAddItemState.ViewState.Content.SecureNotes(
originalCipher = cipherView,
name = "mockName-1",
folderName = "mockFolder-1".asText(),
favorite = false,
masterPasswordReprompt = true,
notes = "mockNotes-1",
ownership = "mockOwnership-1",
)
val result = secureNotesItemType.toCipherView()
assertEquals(
cipherView.copy(
name = "mockName-1",
notes = "mockNotes-1",
type = CipherType.SECURE_NOTE,
secureNote = SecureNoteView(SecureNoteType.GENERIC),
reprompt = CipherRepromptType.PASSWORD,
fields = null,
),
result,
)
}
}
private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView(
id = "id1234",
organizationId = null,
folderId = null,
collectionIds = emptyList(),
key = null,
name = "cipher",
notes = "Lots of notes",
type = CipherType.LOGIN,
login = null,
identity = null,
card = null,
secureNote = null,
favorite = false,
reprompt = CipherRepromptType.PASSWORD,
organizationUseTotp = false,
edit = false,
viewPassword = false,
localData = null,
attachments = null,
fields = listOf(
FieldView(
name = "text",
value = "value",
type = FieldType.TEXT,
linkedId = null,
),
FieldView(
name = "hidden",
value = "value",
type = FieldType.HIDDEN,
linkedId = null,
),
FieldView(
name = "boolean",
value = "true",
type = FieldType.BOOLEAN,
linkedId = null,
),
FieldView(
name = "linked username",
value = null,
type = FieldType.LINKED,
linkedId = 100U,
),
FieldView(
name = "linked password",
value = null,
type = FieldType.LINKED,
linkedId = 101U,
),
),
passwordHistory = listOf(
PasswordHistoryView(
password = "old_password",
lastUsedDate = Instant.ofEpochSecond(1_000L),
),
),
creationDate = Instant.ofEpochSecond(1_000L),
deletedDate = null,
revisionDate = Instant.ofEpochSecond(1_000L),
)
private val DEFAULT_LOGIN_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy(
type = CipherType.LOGIN,
login = LoginView(
username = "username",
password = "password",
passwordRevisionDate = Instant.ofEpochSecond(1_000L),
uris = listOf(
LoginUriView(
uri = "www.example.com",
match = null,
),
),
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
autofillOnPageLoad = false,
),
)
private val DEFAULT_SECURE_NOTES_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy(
type = CipherType.SECURE_NOTE,
secureNote = SecureNoteView(type = SecureNoteType.GENERIC),
)