BIT-1205: Save login items (Encryption) (#295)

This commit is contained in:
Ramsey Smith 2023-11-30 08:50:10 -07:00 committed by Álison Fernandes
parent 9fcd2b1690
commit b279633166
15 changed files with 633 additions and 29 deletions

View file

@ -21,6 +21,11 @@ interface VaultSdkSource {
*/
suspend fun initializeCrypto(request: InitUserCryptoRequest): Result<InitializeCryptoResult>
/**
* Encrypts a [CipherView] returning a [Cipher] wrapped in a [Result].
*/
suspend fun encryptCipher(cipherView: CipherView): Result<Cipher>
/**
* Decrypts a [Cipher] returning a [CipherView] wrapped in a [Result].
*/

View file

@ -34,6 +34,9 @@ class VaultSdkSourceImpl(
}
}
override suspend fun encryptCipher(cipherView: CipherView): Result<Cipher> =
runCatching { clientVault.ciphers().encrypt(cipherView) }
override suspend fun decryptCipher(cipher: Cipher): Result<CipherView> =
runCatching { clientVault.ciphers().decrypt(cipher) }

View file

@ -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<String, String>,
): VaultUnlockResult
/**
* Attempt to create a cipher.
*/
suspend fun createCipher(cipherView: CipherView): CreateCipherResult
}

View file

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

View file

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

View file

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

View file

@ -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<Field>.toEncryptedNetworkFieldList(): List<SyncResponseJson.Cipher.Field> =
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<LoginUri>.toEncryptedNetworkUriList(): List<SyncResponseJson.Cipher.Login.Uri> =
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<PasswordHistory>.toEncryptedNetworkPasswordHistoryList(): List<SyncResponseJson.Cipher.PasswordHistory> =
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<SyncResponseJson.Cipher>.toEncryptedSdkCipherList(): List<Cipher> =
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 =

View file

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

View file

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

View file

@ -99,6 +99,29 @@ class VaultSdkSourceTest {
}
}
@Test
fun `decryptCipher should call SDK and return a Result with correct data`() = runBlocking {
val mockCipher = mockk<CipherView>()
val expectedResult = mockk<Cipher>()
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<Cipher>()

View file

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

View file

@ -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
/**

View file

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

View file

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

View file

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