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 GitHub
parent e70e526b93
commit d52114232b
8 changed files with 843 additions and 98 deletions

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