diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt index e6510415f..5ef513f7e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt @@ -21,6 +21,11 @@ interface VaultSdkSource { */ suspend fun initializeCrypto(request: InitUserCryptoRequest): Result + /** + * Encrypts a [CipherView] returning a [Cipher] wrapped in a [Result]. + */ + suspend fun encryptCipher(cipherView: CipherView): Result + /** * Decrypts a [Cipher] returning a [CipherView] wrapped in a [Result]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt index 45253b5d9..4e3acb09b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt @@ -34,6 +34,9 @@ class VaultSdkSourceImpl( } } + override suspend fun encryptCipher(cipherView: CipherView): Result = + runCatching { clientVault.ciphers().encrypt(cipherView) } + override suspend fun decryptCipher(cipher: Cipher): Result = runCatching { clientVault.ciphers().decrypt(cipher) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index 5ac1dcab0..f17273725 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -4,6 +4,7 @@ import com.bitwarden.core.CipherView import com.bitwarden.core.FolderView import com.bitwarden.core.Kdf import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.data.vault.repository.model.VaultState @@ -75,4 +76,9 @@ interface VaultRepository { privateKey: String, organizationalKeys: Map, ): VaultUnlockResult + + /** + * Attempt to create a cipher. + */ + suspend fun createCipher(cipherView: CipherView): CreateCipherResult } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index c779416a3..8b6b6af25 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -14,12 +14,15 @@ import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.util.map import com.x8bit.bitwarden.data.platform.util.flatMap import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson +import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource +import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.SendData 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.toEncryptedNetworkCipher import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSendList @@ -44,6 +47,7 @@ import kotlinx.coroutines.launch @Suppress("TooManyFunctions") class VaultRepositoryImpl constructor( private val syncService: SyncService, + private val ciphersService: CiphersService, private val vaultSdkSource: VaultSdkSource, private val authDiskSource: AuthDiskSource, dispatcherManager: DispatcherManager, @@ -227,6 +231,25 @@ class VaultRepositoryImpl constructor( .onCompletion { willSyncAfterUnlock = false } .first() + override suspend fun createCipher(cipherView: CipherView): CreateCipherResult = + vaultSdkSource + .encryptCipher(cipherView = cipherView) + .flatMap { cipher -> + ciphersService + .createCipher( + body = cipher.toEncryptedNetworkCipher(), + ) + } + .fold( + onFailure = { + CreateCipherResult.Error + }, + onSuccess = { + sync() + CreateCipherResult.Success + }, + ) + // TODO: This is temporary. Eventually this needs to be based on the presence of various // user keys but this will likely require SDK updates to support this (BIT-1190). private fun setVaultToUnlocked(userId: String) { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt index b11bcde59..2d7149b76 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.vault.repository.di import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.repository.VaultRepository @@ -23,11 +24,13 @@ object VaultRepositoryModule { @Singleton fun providesVaultRepository( syncService: SyncService, + ciphersService: CiphersService, vaultSdkSource: VaultSdkSource, authDiskSource: AuthDiskSource, dispatcherManager: DispatcherManager, ): VaultRepository = VaultRepositoryImpl( syncService = syncService, + ciphersService = ciphersService, vaultSdkSource = vaultSdkSource, authDiskSource = authDiskSource, dispatcherManager = dispatcherManager, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/CreateCipherResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/CreateCipherResult.kt new file mode 100644 index 000000000..a224d3660 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/CreateCipherResult.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.data.vault.repository.model + +/** + * Models result of creating a cipher. + */ +sealed class CreateCipherResult { + + /** + * Cipher created successfully. + */ + data object Success : CreateCipherResult() + + /** + * Generic error while creating cipher. + */ + data object Error : CreateCipherResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt index 56ff1f915..be405be9a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt @@ -1,6 +1,8 @@ @file:Suppress("TooManyFunctions") + package com.x8bit.bitwarden.data.vault.repository.util +import CipherJsonRequest import com.bitwarden.core.Attachment import com.bitwarden.core.Card import com.bitwarden.core.Cipher @@ -18,11 +20,205 @@ import com.bitwarden.core.UriMatchType import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherRepromptTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.FieldTypeJson +import com.x8bit.bitwarden.data.vault.datasource.network.model.LinkedIdTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SecureNoteTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UriMatchTypeJson +import java.time.LocalDateTime import java.time.ZoneOffset +/** + * Converts a Bitwarden SDK [Cipher] object to a corresponding + * [SyncResponseJson.Cipher] object. + */ +fun Cipher.toEncryptedNetworkCipher(): CipherJsonRequest = + CipherJsonRequest( + notes = notes, + reprompt = reprompt.toNetworkRepromptType(), + passwordHistory = passwordHistory?.toEncryptedNetworkPasswordHistoryList(), + lastKnownRevisionDate = LocalDateTime.ofInstant(revisionDate, ZoneOffset.UTC), + 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(), + ) + +/** + * Converts a Bitwarden SDK [Card] object to a corresponding + * [SyncResponseJson.Cipher.Card] object. + */ +private fun Card.toEncryptedNetworkCard(): SyncResponseJson.Cipher.Card = + SyncResponseJson.Cipher.Card( + number = number, + expMonth = expMonth, + code = code, + expirationYear = expYear, + cardholderName = cardholderName, + brand = brand, + ) + +/** + * Converts a list of Bitwarden SDK [Field] objects to a corresponding + * list of [SyncResponseJson.Cipher.Field] objects. + */ +private fun List.toEncryptedNetworkFieldList(): List = + this.map { it.toEncryptedNetworkField() } + +/** + * Converts a Bitwarden SDK [Field] object to a corresponding + * [SyncResponseJson.Cipher.Field] object. + */ +private fun Field.toEncryptedNetworkField(): SyncResponseJson.Cipher.Field = + SyncResponseJson.Cipher.Field( + linkedIdType = linkedId?.toNetworkLinkedIdType(), + name = name, + type = type.toNetworkFieldType(), + value = value, + ) + +private fun UInt.toNetworkLinkedIdType(): LinkedIdTypeJson = + LinkedIdTypeJson.values().first { this == it.value } + +/** + * Converts a Bitwarden SDK [FieldType] object to a corresponding + * [FieldTypeJson] object. + */ +private fun FieldType.toNetworkFieldType(): FieldTypeJson = + when (this) { + FieldType.TEXT -> FieldTypeJson.TEXT + FieldType.HIDDEN -> FieldTypeJson.HIDDEN + FieldType.BOOLEAN -> FieldTypeJson.BOOLEAN + FieldType.LINKED -> FieldTypeJson.LINKED + } + +/** + * Converts a Bitwarden SDK [Identity] object to a corresponding + * [SyncResponseJson.Cipher.Identity] object. + */ +private fun Identity.toEncryptedNetworkIdentity(): SyncResponseJson.Cipher.Identity = + SyncResponseJson.Cipher.Identity( + title = title, + middleName = middleName, + firstName = firstName, + lastName = lastName, + address1 = address1, + address2 = address2, + address3 = address3, + city = city, + state = state, + postalCode = postalCode, + country = country, + company = company, + email = email, + phone = phone, + ssn = ssn, + username = username, + passportNumber = passportNumber, + licenseNumber = licenseNumber, + ) + +/** + * Converts a Bitwarden SDK [SecureNote] object to a corresponding + * [SyncResponseJson.Cipher.SecureNote] object. + */ +private fun SecureNote.toEncryptedNetworkSecureNote(): SyncResponseJson.Cipher.SecureNote = + SyncResponseJson.Cipher.SecureNote( + type = when (type) { + SecureNoteType.GENERIC -> SecureNoteTypeJson.GENERIC + }, + ) + +/** + * Converts a list of Bitwarden SDK [LoginUri] objects to a corresponding + * list of [SyncResponseJson.Cipher.Login.Uri] objects. + */ +private fun List.toEncryptedNetworkUriList(): List = + this.map { it.toEncryptedNetworkUri() } + +/** + * Converts a Bitwarden SDK [LoginUri] object to a corresponding + * [SyncResponseJson.Cipher.Login.Uri] object. + */ +private fun LoginUri.toEncryptedNetworkUri(): SyncResponseJson.Cipher.Login.Uri = + SyncResponseJson.Cipher.Login.Uri( + uriMatchType = match?.toNetworkMatchType(), + uri = uri, + ) + +private fun UriMatchType.toNetworkMatchType(): UriMatchTypeJson = + when (this) { + UriMatchType.DOMAIN -> UriMatchTypeJson.DOMAIN + UriMatchType.HOST -> UriMatchTypeJson.HOST + UriMatchType.STARTS_WITH -> UriMatchTypeJson.STARTS_WITH + UriMatchType.EXACT -> UriMatchTypeJson.EXACT + UriMatchType.REGULAR_EXPRESSION -> UriMatchTypeJson.REGULAR_EXPRESSION + UriMatchType.NEVER -> UriMatchTypeJson.NEVER + } + +/** + * Converts a Bitwarden SDK [Login] object to a corresponding + * [SyncResponseJson.Cipher.Login] object. + */ +private fun Login.toEncryptedNetworkLogin(): SyncResponseJson.Cipher.Login = + SyncResponseJson.Cipher.Login( + uris = uris?.toEncryptedNetworkUriList(), + totp = totp, + password = password, + passwordRevisionDate = passwordRevisionDate?.let { + LocalDateTime.ofInstant(it, ZoneOffset.UTC) + }, + shouldAutofillOnPageLoad = autofillOnPageLoad, + uri = uris?.firstOrNull()?.uri, + username = username, + ) + +/** + * Converts a list of Bitwarden SDK [PasswordHistory] objects to a corresponding + * list of [SyncResponseJson.Cipher.PasswordHistory] objects. + */ +@Suppress("MaxLineLength") +private fun List.toEncryptedNetworkPasswordHistoryList(): List = + this.map { it.toEncryptedNetworkPasswordHistory() } + +/** + * Converts a Bitwarden SDK [PasswordHistory] object to a corresponding + * [SyncResponseJson.Cipher.PasswordHistory] object. + */ +@Suppress("MaxLineLength") +private fun PasswordHistory.toEncryptedNetworkPasswordHistory(): SyncResponseJson.Cipher.PasswordHistory = + SyncResponseJson.Cipher.PasswordHistory( + password = password, + lastUsedDate = LocalDateTime.ofInstant(lastUsedDate, ZoneOffset.UTC), + ) + +/** + * Converts a Bitwarden SDK [CipherRepromptType] object to a corresponding + * [CipherRepromptTypeJson] object. + */ +private fun CipherRepromptType.toNetworkRepromptType(): CipherRepromptTypeJson = + when (this) { + CipherRepromptType.NONE -> CipherRepromptTypeJson.NONE + CipherRepromptType.PASSWORD -> CipherRepromptTypeJson.PASSWORD + } + +/** + * Converts a Bitwarden SDK [CipherType] object to a corresponding + * [CipherTypeJson] object. + */ +private fun CipherType.toNetworkCipherType(): CipherTypeJson = + when (this) { + CipherType.LOGIN -> CipherTypeJson.LOGIN + CipherType.SECURE_NOTE -> CipherTypeJson.SECURE_NOTE + CipherType.CARD -> CipherTypeJson.CARD + CipherType.IDENTITY -> CipherTypeJson.IDENTITY + } + /** * Converts a list of [SyncResponseJson.Cipher] objects to a list of corresponding * Bitwarden SDK [Cipher] objects. @@ -31,7 +227,7 @@ fun List.toEncryptedSdkCipherList(): List = map { it.toEncryptedSdkCipher() } /** - * Converts a of [SyncResponseJson.Cipher] object to a corresponding + * Converts a [SyncResponseJson.Cipher] object to a corresponding * Bitwarden SDK [Cipher] object. */ fun SyncResponseJson.Cipher.toEncryptedSdkCipher(): Cipher = diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt index 5dbe9bd05..4e53b73dc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt @@ -4,10 +4,13 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState.ItemType.Card.displayStringResId import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState.ItemType.Identity.displayStringResId import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState.ItemType.SecureNotes.displayStringResId +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toCipherView import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -31,6 +34,7 @@ private const val KEY_STATE = "state" @Suppress("TooManyFunctions") class VaultAddItemViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, + private val vaultRepository: VaultRepository, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: INITIAL_STATE, ) { @@ -58,6 +62,10 @@ class VaultAddItemViewModel @Inject constructor( is VaultAddItemAction.ItemType.LoginType -> { handleAddLoginTypeAction(action) } + + is VaultAddItemAction.Internal.CreateCipherResultReceive -> { + handleCreateCipherResultReceive(action) + } } } @@ -67,9 +75,11 @@ class VaultAddItemViewModel @Inject constructor( private fun handleSaveClick() { viewModelScope.launch { - sendEvent( - event = VaultAddItemEvent.ShowToast( - message = "Save Item", + sendAction( + action = VaultAddItemAction.Internal.CreateCipherResultReceive( + createCipherResult = vaultRepository.createCipher( + cipherView = stateFlow.value.selectedType.toCipherView(), + ), ), ) } @@ -106,6 +116,7 @@ class VaultAddItemViewModel @Inject constructor( //region Add Login Item Type Handlers + @Suppress("LongMethod") private fun handleAddLoginTypeAction( action: VaultAddItemAction.ItemType.LoginType, ) { @@ -358,6 +369,30 @@ class VaultAddItemViewModel @Inject constructor( //endregion Add Login Item Type Handlers + //region Internal Type Handlers + + @Suppress("MaxLineLength") + private fun handleCreateCipherResultReceive(action: VaultAddItemAction.Internal.CreateCipherResultReceive) { + when (action.createCipherResult) { + is CreateCipherResult.Error -> { + // TODO Display error dialog BIT-501 + sendEvent( + event = VaultAddItemEvent.ShowToast( + message = "Save Item Failure", + ), + ) + } + + is CreateCipherResult.Success -> { + sendEvent( + event = VaultAddItemEvent.NavigateBack, + ) + } + } + } + + //endregion Internal Type Handlers + //region Utility Functions private inline fun updateLoginType( @@ -669,4 +704,17 @@ sealed class VaultAddItemAction { data object AddNewCustomFieldClick : LoginType() } } + + /** + * Models actions that the [VaultAddItemViewModel] itself might send. + */ + sealed class Internal : VaultAddItemAction() { + + /** + * Indicates a result for creating a cipher has been received. + */ + data class CreateCipherResultReceive( + val createCipherResult: CreateCipherResult, + ) : Internal() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt index 260756081..587788392 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -1,10 +1,16 @@ 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.LoginUriView +import com.bitwarden.core.LoginView +import com.bitwarden.core.UriMatchType import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState +import java.time.Instant /** * Transforms a [CipherView] into a [VaultState.ViewState.VaultItem]. @@ -74,3 +80,86 @@ fun VaultData.toViewState(): VaultState.ViewState = trashItemsCount = 0, ) } + +/** + * Transforms a [VaultAddItemState.ItemType] into [CipherView]. + */ +fun VaultAddItemState.ItemType.toCipherView(): CipherView = + when (this) { + is VaultAddItemState.ItemType.Card -> toCardCipherView() + is VaultAddItemState.ItemType.Identity -> toIdentityCipherView() + is VaultAddItemState.ItemType.Login -> toLoginCipherView() + is VaultAddItemState.ItemType.SecureNotes -> toSecureNotesCipherView() + } + +/** + * Transforms [VaultAddItemState.ItemType.SecureNotes] into [CipherView]. + */ +private fun VaultAddItemState.ItemType.SecureNotes.toSecureNotesCipherView(): CipherView = + TODO("create SecureNotes CipherView BIT-509") + +/** + * Transforms [VaultAddItemState.ItemType.Login] into [CipherView]. + */ +private fun VaultAddItemState.ItemType.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, + type = CipherType.LOGIN, + login = LoginView( + username = username, + password = password, + passwordRevisionDate = null, + uris = listOf( + LoginUriView( + uri = uri, + // TODO implement uri settings in BIT-1094 + match = UriMatchType.DOMAIN, + ), + ), + // TODO implement totp in BIT-1066 + totp = null, + autofillOnPageLoad = false, + ), + 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 = 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(), + ) + +/** + * Transforms [VaultAddItemState.ItemType.Identity] into [CipherView]. + */ +private fun VaultAddItemState.ItemType.Identity.toIdentityCipherView(): CipherView = + TODO("create Identity CipherView BIT-508") + +/** + * Transforms [VaultAddItemState.ItemType.Card] into [CipherView]. + */ +private fun VaultAddItemState.ItemType.Card.toCardCipherView(): CipherView = + TODO("create Card CipherView BIT-668") diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt index f2e42afd9..bffc0c680 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt @@ -99,6 +99,29 @@ class VaultSdkSourceTest { } } + @Test + fun `decryptCipher should call SDK and return a Result with correct data`() = runBlocking { + val mockCipher = mockk() + val expectedResult = mockk() + coEvery { + clientVault.ciphers().encrypt( + cipherView = mockCipher, + ) + } returns expectedResult + val result = vaultSdkSource.encryptCipher( + cipherView = mockCipher, + ) + assertEquals( + expectedResult.asSuccess(), + result, + ) + coVerify { + clientVault.ciphers().encrypt( + cipherView = mockCipher, + ) + } + } + @Test fun `Cipher decrypt should call SDK and return a Result with correct data`() = runBlocking { val mockCipher = mockk() diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt index 9eb1ea7eb..ccc110db5 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt @@ -152,7 +152,7 @@ fun createMockPasswordHistoryView(number: Int): PasswordHistoryView = ) /** - * Create a mock [SecureNoteView] with a given [number]. + * Create a mock [SecureNoteView]. */ fun createMockSecureNoteView(): SecureNoteView = SecureNoteView( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index cb1f9f044..7f082a987 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -16,7 +16,10 @@ import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asSuccess +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockSyncResponse +import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult @@ -26,6 +29,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkCipher import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFolder import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkSend import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView +import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.data.vault.repository.model.VaultState @@ -50,9 +54,11 @@ class VaultRepositoryTest { private val dispatcherManager: DispatcherManager = FakeDispatcherManager() private val fakeAuthDiskSource = FakeAuthDiskSource() private val syncService: SyncService = mockk() + private val ciphersService: CiphersService = mockk() private val vaultSdkSource: VaultSdkSource = mockk() private val vaultRepository = VaultRepositoryImpl( syncService = syncService, + ciphersService = ciphersService, vaultSdkSource = vaultSdkSource, authDiskSource = fakeAuthDiskSource, dispatcherManager = dispatcherManager, @@ -1381,6 +1387,76 @@ class VaultRepositoryTest { } } + @Test + fun `createCipher with encryptCipher failure should return CreateCipherResult failure`() = + runTest { + val mockCipherView = createMockCipherView(number = 1) + coEvery { + vaultSdkSource.encryptCipher(cipherView = mockCipherView) + } returns IllegalStateException().asFailure() + + val result = vaultRepository.createCipher(cipherView = mockCipherView) + + assertEquals( + CreateCipherResult.Error, + result, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `createCipher with ciphersService createCipher failure should return CreateCipherResult failure`() = runTest { + val mockCipherView = createMockCipherView(number = 1) + coEvery { + vaultSdkSource.encryptCipher(cipherView = mockCipherView) + } returns createMockSdkCipher(number = 1).asSuccess() + coEvery { + ciphersService.createCipher( + body = createMockCipherJsonRequest(number = 1), + ) + } returns IllegalStateException().asFailure() + + val result = vaultRepository.createCipher(cipherView = mockCipherView) + + assertEquals( + CreateCipherResult.Error, + result, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `createCipher with ciphersService createCipher success should return CreateCipherResult success`() = runTest { + val mockCipherView = createMockCipherView(number = 1) + coEvery { + vaultSdkSource.encryptCipher(cipherView = mockCipherView) + } returns createMockSdkCipher(number = 1).asSuccess() + coEvery { + ciphersService.createCipher( + body = createMockCipherJsonRequest(number = 1), + ) + } returns createMockCipher(number = 1).asSuccess() + coEvery { + syncService.sync() + } returns Result.success(createMockSyncResponse(1)) + coEvery { + vaultSdkSource.decryptCipherList(listOf(createMockSdkCipher(1))) + } returns listOf(createMockCipherView(1)).asSuccess() + coEvery { + vaultSdkSource.decryptFolderList(listOf(createMockSdkFolder(1))) + } returns listOf(createMockFolderView(1)).asSuccess() + coEvery { + vaultSdkSource.decryptSendList(listOf(createMockSdkSend(1))) + } returns listOf(createMockSendView(1)).asSuccess() + + val result = vaultRepository.createCipher(cipherView = mockCipherView) + + assertEquals( + CreateCipherResult.Success, + result, + ) + } + //region Helper functions /** diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt index abaa7b799..e2c258d04 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt @@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.UriMatchTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachment import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCard import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockField import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockIdentity import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockLogin @@ -31,6 +32,16 @@ import org.junit.Test class VaultSdkCipherExtensionsTest { + @Test + fun `toEncryptedNetworkCipher should convert an Sdk Cipher to a Network Cipher`() { + val sdkCipher = createMockSdkCipher(number = 1) + val syncCipher = sdkCipher.toEncryptedNetworkCipher() + assertEquals( + createMockCipherJsonRequest(number = 1), + syncCipher, + ) + } + @Test fun `toEncryptedSdkCipherList should convert list of Network Cipher to List of Sdk Cipher`() { val syncCiphers = listOf( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt index 363b2743c..eaee53815 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt @@ -2,7 +2,11 @@ package com.x8bit.bitwarden.ui.vault.feature.additem import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.coEvery +import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach @@ -13,10 +17,11 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { private val initialState = createVaultAddLoginItemState() private val initialSavedStateHandle = createSavedStateHandleWithState(initialState) + private val vaultRepository: VaultRepository = mockk() @Test fun `initial state should be correct`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() viewModel.stateFlow.test { assertEquals(initialState, awaitItem()) } @@ -24,7 +29,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Test fun `CloseClick should emit NavigateBack`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() viewModel.eventFlow.test { viewModel.actionChannel.trySend(VaultAddItemAction.CloseClick) assertEquals(VaultAddItemEvent.NavigateBack, awaitItem()) @@ -32,17 +37,31 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { } @Test - fun `SaveClick should emit ShowToast`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + fun `SaveClick createCipher success should emit NavigateBack`() = runTest { + val viewModel = createAddVaultItemViewModel() + coEvery { + vaultRepository.createCipher(any()) + } returns CreateCipherResult.Success viewModel.eventFlow.test { viewModel.actionChannel.trySend(VaultAddItemAction.SaveClick) - assertEquals(VaultAddItemEvent.ShowToast("Save Item"), awaitItem()) + assertEquals(VaultAddItemEvent.NavigateBack, awaitItem()) } } + @Test + fun `SaveClick createCipher error should emit ShowToast`() = runTest { + val viewModel = createAddVaultItemViewModel() + coEvery { + vaultRepository.createCipher(any()) + } returns CreateCipherResult.Error + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(VaultAddItemAction.SaveClick) + assertEquals(VaultAddItemEvent.ShowToast("Save Item Failure"), awaitItem()) + } + } @Test fun `TypeOptionSelect LOGIN should switch to LoginItem`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() val action = VaultAddItemAction.TypeOptionSelect(VaultAddItemState.ItemTypeOption.LOGIN) viewModel.actionChannel.trySend(action) @@ -58,12 +77,12 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @BeforeEach fun setup() { - viewModel = VaultAddItemViewModel(initialSavedStateHandle) + viewModel = createAddVaultItemViewModel() } @Test fun `NameTextChange should update name in LoginItem`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() val action = VaultAddItemAction.ItemType.LoginType.NameTextChange("newName") viewModel.actionChannel.trySend(action) @@ -80,7 +99,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test fun `UsernameTextChange should update username in LoginItem`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() val action = VaultAddItemAction.ItemType.LoginType.UsernameTextChange("newUsername") viewModel.actionChannel.trySend(action) @@ -97,7 +116,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test fun `PasswordTextChange should update password in LoginItem`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() val action = VaultAddItemAction.ItemType.LoginType.PasswordTextChange("newPassword") viewModel.actionChannel.trySend(action) @@ -113,7 +132,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Test fun `UriTextChange should update uri in LoginItem`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() val action = VaultAddItemAction.ItemType.LoginType.UriTextChange("newUri") viewModel.actionChannel.trySend(action) @@ -129,7 +148,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Test fun `FolderChange should update folder in LoginItem`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() val action = VaultAddItemAction.ItemType.LoginType.FolderChange("newFolder") viewModel.actionChannel.trySend(action) @@ -145,7 +164,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Test fun `ToggleFavorite should update favorite in LoginItem`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() val action = VaultAddItemAction.ItemType.LoginType.ToggleFavorite(true) viewModel.actionChannel.trySend(action) @@ -163,7 +182,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Test fun `ToggleMasterPasswordReprompt should update masterPasswordReprompt in LoginItem`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() val action = VaultAddItemAction.ItemType.LoginType.ToggleMasterPasswordReprompt( isMasterPasswordReprompt = true, ) @@ -181,7 +200,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Test fun `NotesTextChange should update notes in LoginItem`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() val action = VaultAddItemAction.ItemType.LoginType.NotesTextChange(notes = "newNotes") viewModel.actionChannel.trySend(action) @@ -198,7 +217,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test fun `OwnershipChange should update ownership in LoginItem`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() val action = VaultAddItemAction.ItemType.LoginType.OwnershipChange(ownership = "newOwner") @@ -217,7 +236,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Test fun `OpenUsernameGeneratorClick should emit ShowToast with 'Open Username Generator' message`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() viewModel.eventFlow.test { viewModel.actionChannel.trySend( @@ -233,7 +252,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Test fun `PasswordCheckerClick should emit ShowToast with 'Password Checker' message`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() viewModel.eventFlow.test { viewModel @@ -248,7 +267,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Test fun `OpenPasswordGeneratorClick should emit ShowToast with 'Open Password Generator' message`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() viewModel.eventFlow.test { viewModel @@ -265,7 +284,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test fun `SetupTotpClick should emit ShowToast with 'Setup TOTP' message`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() viewModel.eventFlow.test { viewModel.actionChannel.trySend(VaultAddItemAction.ItemType.LoginType.SetupTotpClick) @@ -276,7 +295,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test fun `UriSettingsClick should emit ShowToast with 'URI Settings' message`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() viewModel.eventFlow.test { viewModel.actionChannel.trySend(VaultAddItemAction.ItemType.LoginType.UriSettingsClick) @@ -286,7 +305,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Test fun `AddNewUriClick should emit ShowToast with 'Add New URI' message`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() viewModel.eventFlow.test { viewModel @@ -301,7 +320,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Test fun `TooltipClick should emit ShowToast with 'Tooltip' message`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() viewModel.eventFlow.test { viewModel @@ -316,7 +335,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { @Test fun `AddNewCustomFieldClick should emit ShowToast with 'Add New Custom Field' message`() = runTest { - val viewModel = VaultAddItemViewModel(initialSavedStateHandle) + val viewModel = createAddVaultItemViewModel() viewModel.eventFlow.test { viewModel @@ -359,4 +378,10 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { SavedStateHandle().apply { set("state", state) } + + private fun createAddVaultItemViewModel(): VaultAddItemViewModel = + VaultAddItemViewModel( + savedStateHandle = initialSavedStateHandle, + vaultRepository = vaultRepository, + ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt index 53461fdef..779a6ccfb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -1,15 +1,33 @@ 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.LoginUriView +import com.bitwarden.core.LoginView +import com.bitwarden.core.UriMatchType import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import java.time.Instant class VaultDataExtensionsTest { + @AfterEach + fun tearDown() { + // Some individual tests call mockkStatic so we will make sure this is always undone. + unmockkStatic(Instant::class) + } + @Test fun `toViewState should transform full VaultData into ViewState Content`() { val vaultData = VaultData( @@ -84,4 +102,65 @@ class VaultDataExtensionsTest { actual, ) } + + @Test + fun `toCipherView should transform Login ItemType to CipherView`() { + mockkStatic(Instant::class) + every { Instant.now() } returns Instant.MIN + val loginItemType = VaultAddItemState.ItemType.Login( + name = "mockName-1", + username = "mockUsername-1", + password = "mockPassword-1", + uri = "mockUri-1", + folder = "mockFolder-1", + favorite = false, + masterPasswordReprompt = false, + notes = "mockNotes-1", + ownership = "mockOwnership-1", + ) + + val result = loginItemType.toCipherView() + + assertEquals( + CipherView( + id = null, + organizationId = null, + folderId = null, + collectionIds = emptyList(), + key = null, + name = "mockName-1", + notes = "mockNotes-1", + type = CipherType.LOGIN, + login = LoginView( + username = "mockUsername-1", + password = "mockPassword-1", + passwordRevisionDate = null, + uris = listOf( + LoginUriView( + uri = "mockUri-1", + match = UriMatchType.DOMAIN, + ), + ), + totp = null, + autofillOnPageLoad = false, + ), + identity = null, + card = null, + secureNote = null, + favorite = false, + reprompt = CipherRepromptType.NONE, + organizationUseTotp = false, + edit = true, + viewPassword = true, + localData = null, + attachments = null, + fields = null, + passwordHistory = null, + creationDate = Instant.MIN, + deletedDate = null, + revisionDate = Instant.MIN, + ), + result, + ) + } }