BIT-1078: Save login items (Networking) (#272)

This commit is contained in:
Ramsey Smith 2023-11-22 21:13:05 -07:00 committed by Álison Fernandes
parent 1f337e94f0
commit d36601fa3a
11 changed files with 324 additions and 15 deletions

View file

@ -16,7 +16,7 @@ class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
private val dateTimeFormatterDeserialization = DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss.[SSSSSSS][SSSSSS][SSSSS][SSSS][SSS][SS][S]'Z'")
private val dateTimeFormatterSerialization =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSS'Z'")
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor(serialName = "LocalDateTime", kind = PrimitiveKind.STRING)

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.vault.datasource.network.api
import CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Defines raw calls under the /ciphers API with authentication applied.
*/
interface CiphersApi {
/**
* Create a cipher.
*/
@POST("ciphers")
suspend fun createCipher(@Body body: CipherJsonRequest): Result<SyncResponseJson.Cipher>
}

View file

@ -3,6 +3,8 @@ package com.x8bit.bitwarden.data.vault.datasource.network.di
import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService
import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncServiceImpl
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService
import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersServiceImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -17,6 +19,14 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object VaultNetworkModule {
@Provides
@Singleton
fun provideCiphersService(
retrofits: Retrofits,
): CiphersService = CiphersServiceImpl(
ciphersApi = retrofits.authenticatedApiRetrofit.create(),
)
@Provides
@Singleton
fun provideSyncService(

View file

@ -0,0 +1,71 @@
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.SyncResponseJson
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.LocalDateTime
/**
* Represents a cipher request.
*
* @property notes The notes of the cipher (nullable).
* @property reprompt The reprompt of the cipher.
* @property passwordHistory A list of password history objects
* associated with the cipher (nullable).
* @property type The type of cipher.
* @property login The login of the cipher.
* @property secureNote The secure note of the cipher.
* @property folderId The folder ID of the cipher (nullable).
* @property organizationId The organization ID of the cipher (nullable).
* @property identity The identity of the cipher.
* @property name The name of the cipher (nullable).
* @property fields A list of fields associated with the cipher (nullable).
* @property isFavorite If the cipher is a favorite.
* @property card The card of the cipher.
*/
@Serializable
data class CipherJsonRequest(
@SerialName("notes")
val notes: String?,
@SerialName("reprompt")
val reprompt: CipherRepromptTypeJson,
@SerialName("passwordHistory")
val passwordHistory: List<SyncResponseJson.Cipher.PasswordHistory>?,
@SerialName("lastKnownRevisionDate")
@Contextual
val lastKnownRevisionDate: LocalDateTime?,
@SerialName("type")
val type: CipherTypeJson,
@SerialName("login")
val login: SyncResponseJson.Cipher.Login?,
@SerialName("secureNote")
val secureNote: SyncResponseJson.Cipher.SecureNote?,
@SerialName("folderId")
val folderId: String?,
@SerialName("organizationId")
val organizationId: String?,
@SerialName("identity")
val identity: SyncResponseJson.Cipher.Identity?,
@SerialName("name")
val name: String?,
@SerialName("fields")
val fields: List<SyncResponseJson.Cipher.Field>?,
@SerialName("favorite")
val isFavorite: Boolean,
@SerialName("card")
val card: SyncResponseJson.Cipher.Card?,
)

View file

@ -0,0 +1,14 @@
package com.x8bit.bitwarden.data.vault.datasource.network.service
import CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
/**
* Provides an API for querying ciphers endpoints.
*/
interface CiphersService {
/**
* Attempt to create a cipher.
*/
suspend fun createCipher(body: CipherJsonRequest): Result<SyncResponseJson.Cipher>
}

View file

@ -0,0 +1,12 @@
package com.x8bit.bitwarden.data.vault.datasource.network.service
import CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.api.CiphersApi
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
class CiphersServiceImpl constructor(
private val ciphersApi: CiphersApi,
) : CiphersService {
override suspend fun createCipher(body: CipherJsonRequest): Result<SyncResponseJson.Cipher> =
ciphersApi.createCipher(body = body)
}

View file

@ -10,21 +10,22 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
* Transforms a [CipherView] into a [VaultState.ViewState.VaultItem].
*/
@Suppress("MagicNumber")
private fun CipherView.toVaultItem(): VaultState.ViewState.VaultItem =
when (type) {
private fun CipherView.toVaultItemOrNull(): VaultState.ViewState.VaultItem? {
val id = this.id ?: return null
return when (type) {
CipherType.LOGIN -> VaultState.ViewState.VaultItem.Login(
id = id.toString(),
id = id,
name = name.asText(),
username = login?.username?.asText(),
)
CipherType.SECURE_NOTE -> VaultState.ViewState.VaultItem.SecureNote(
id = id.toString(),
id = id,
name = name.asText(),
)
CipherType.CARD -> VaultState.ViewState.VaultItem.Card(
id = id.toString(),
id = id,
name = name.asText(),
brand = card?.brand?.asText(),
lastFourDigits = card?.number
@ -33,11 +34,12 @@ private fun CipherView.toVaultItem(): VaultState.ViewState.VaultItem =
)
CipherType.IDENTITY -> VaultState.ViewState.VaultItem.Identity(
id = id.toString(),
id = id,
name = name.asText(),
firstName = identity?.firstName?.asText(),
)
}
}
/**
* Transforms [VaultData] into [VaultState.ViewState].
@ -46,24 +48,28 @@ fun VaultData.toViewState(): VaultState.ViewState =
if (cipherViewList.isEmpty() && folderViewList.isEmpty()) {
VaultState.ViewState.NoItems
} else {
// Filter out any items with invalid IDs in the unlikely case they exist
val filteredCipherViewList = cipherViewList.filterNot { it.id.isNullOrBlank() }
VaultState.ViewState.Content(
loginItemsCount = cipherViewList.count { it.type == CipherType.LOGIN },
cardItemsCount = cipherViewList.count { it.type == CipherType.CARD },
identityItemsCount = cipherViewList.count { it.type == CipherType.IDENTITY },
secureNoteItemsCount = cipherViewList.count { it.type == CipherType.SECURE_NOTE },
loginItemsCount = filteredCipherViewList.count { it.type == CipherType.LOGIN },
cardItemsCount = filteredCipherViewList.count { it.type == CipherType.CARD },
identityItemsCount = filteredCipherViewList.count { it.type == CipherType.IDENTITY },
secureNoteItemsCount = filteredCipherViewList
.count { it.type == CipherType.SECURE_NOTE },
favoriteItems = cipherViewList
.filter { it.favorite }
.map { it.toVaultItem() },
.mapNotNull { it.toVaultItemOrNull() },
folderItems = folderViewList.map { folderView ->
VaultState.ViewState.FolderItem(
id = folderView.id,
name = folderView.name.asText(),
itemCount = cipherViewList.count { folderView.id == it.folderId },
itemCount = cipherViewList
.count { !it.id.isNullOrBlank() && folderView.id == it.folderId },
)
},
noFolderItems = cipherViewList
.filter { it.folderId.isNullOrBlank() }
.map { it.toVaultItem() },
.mapNotNull { it.toVaultItemOrNull() },
// TODO need to populate trash item count in BIT-969
trashItemsCount = 0,
)

View file

@ -69,7 +69,7 @@ class LocalDateTimeSerializerTest {
json.parseToJsonElement(
"""
{
"dataAsLocalDateTime": "2023-10-06T17:22:28.4400000Z"
"dataAsLocalDateTime": "2023-10-06T17:22:28.440Z"
}
""",
),

View file

@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.vault.datasource.network.model
import CipherJsonRequest
import java.time.LocalDateTime
/**
* Create a mock [CipherJsonRequest] with a given [number].
*/
fun createMockCipherJsonRequest(number: Int): CipherJsonRequest =
CipherJsonRequest(
organizationId = "mockOrganizationId-$number",
folderId = "mockFolderId-$number",
name = "mockName-$number",
notes = "mockNotes-$number",
type = CipherTypeJson.LOGIN,
login = createMockLogin(number = number),
card = createMockCard(number = number),
fields = listOf(createMockField(number = number)),
identity = createMockIdentity(number = number),
isFavorite = false,
passwordHistory = listOf(createMockPasswordHistory(number = number)),
reprompt = CipherRepromptTypeJson.NONE,
secureNote = createMockSecureNote(),
lastKnownRevisionDate = LocalDateTime.parse("2023-10-27T12:00:00"),
)

View file

@ -0,0 +1,123 @@
package com.x8bit.bitwarden.data.vault.datasource.network.service
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import com.x8bit.bitwarden.data.vault.datasource.network.api.CiphersApi
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipherJsonRequest
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import retrofit2.create
class CiphersServiceTest : BaseServiceTest() {
private val ciphersApi: CiphersApi = retrofit.create()
private val ciphersService: CiphersService = CiphersServiceImpl(
ciphersApi = ciphersApi,
)
@Test
fun `createCipher should return the correct response`() = runTest {
server.enqueue(MockResponse().setBody(CREATE_CIPHER_SUCCESS_JSON))
val result = ciphersService.createCipher(
body = createMockCipherJsonRequest(number = 1),
)
assertEquals(
createMockCipher(number = 1),
result.getOrThrow(),
)
}
}
private const val CREATE_CIPHER_SUCCESS_JSON = """
{
"notes": "mockNotes-1",
"attachments": [
{
"fileName": "mockFileName-1",
"size": 1,
"sizeName": "mockSizeName-1",
"id": "mockId-1",
"url": "mockUrl-1",
"key": "mockKey-1"
}
],
"organizationUseTotp": false,
"reprompt": 0,
"edit": false,
"passwordHistory": [
{
"password": "mockPassword-1",
"lastUsedDate": "2023-10-27T12:00:00.00Z"
}
],
"revisionDate": "2023-10-27T12:00:00.00Z",
"type": 1,
"login": {
"uris": [
{
"match": 1,
"uri": "mockUri-1"
}
],
"totp": "mockTotp-1",
"password": "mockPassword-1",
"passwordRevisionDate": "2023-10-27T12:00:00.00Z",
"autofillOnPageLoad": false,
"uri": "mockUri-1",
"username": "mockUsername-1"
},
"creationDate": "2023-10-27T12:00:00.00Z",
"secureNote": {
"type": 0
},
"folderId": "mockFolderId-1",
"organizationId": "mockOrganizationId-1",
"deletedDate": "2023-10-27T12:00:00.00Z",
"identity": {
"passportNumber": "mockPassportNumber-1",
"lastName": "mockLastName-1",
"address3": "mockAddress3-1",
"address2": "mockAddress2-1",
"city": "mockCity-1",
"country": "mockCountry-1",
"address1": "mockAddress1-1",
"postalCode": "mockPostalCode-1",
"title": "mockTitle-1",
"ssn": "mockSsn-1",
"firstName": "mockFirstName-1",
"phone": "mockPhone-1",
"middleName": "mockMiddleName-1",
"company": "mockCompany-1",
"licenseNumber": "mockLicenseNumber-1",
"state": "mockState-1",
"email": "mockEmail-1",
"username": "mockUsername-1"
},
"collectionIds": [
"mockCollectionId-1"
],
"name": "mockName-1",
"id": "mockId-1"
"fields": [
{
"linkedId": 100,
"name": "mockName-1",
"type": 1,
"value": "mockValue-1"
}
],
"viewPassword": false,
"favorite": false,
"card": {
"number": "mockNumber-1",
"expMonth": "mockExpMonth-1",
"code": "mockCode-1",
"expYear": "mockExpirationYear-1",
"cardholderName": "mockCardholderName-1",
"brand": "mockBrand-1"
},
"key": "mockKey-1"
}
"""

View file

@ -54,4 +54,34 @@ class VaultDataExtensionsTest {
actual,
)
}
@Test
fun `toViewState should not transform ciphers with no ID into ViewState items`() {
val vaultData = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1).copy(id = null)),
folderViewList = listOf(createMockFolderView(number = 1)),
)
val actual = vaultData.toViewState()
assertEquals(
VaultState.ViewState.Content(
loginItemsCount = 0,
cardItemsCount = 0,
identityItemsCount = 0,
secureNoteItemsCount = 0,
favoriteItems = emptyList(),
folderItems = listOf(
VaultState.ViewState.FolderItem(
id = "mockId-1",
name = "mockName-1".asText(),
itemCount = 0,
),
),
noFolderItems = emptyList(),
trashItemsCount = 0,
),
actual,
)
}
}