[PM-15054] Add API for importing ciphers

Add an API for importing ciphers, including folders and folder relationships.
This commit is contained in:
Patrick Honkonen 2024-11-19 14:53:18 -05:00
parent d418444dc0
commit 8b7f2f99b8
No known key found for this signature in database
GPG key ID: B63AF42A5531C877
6 changed files with 138 additions and 0 deletions

View file

@ -5,6 +5,8 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonReq
import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonResponse
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
@ -149,4 +151,9 @@ interface CiphersApi {
*/
@GET("ciphers/has-unassigned-ciphers")
suspend fun hasUnassignedCiphers(): NetworkResult<Boolean>
@POST("ciphers/import")
suspend fun importCiphers(
@Body body: ImportCiphersJsonRequest,
): NetworkResult<ImportCiphersResponseJson>
}

View file

@ -0,0 +1,37 @@
package com.x8bit.bitwarden.data.vault.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents an import ciphers request.
*
* @property folders A list of folders to import.
* @property ciphers A list of ciphers to import.
* @property folderRelationships A map of cipher folder relationships to import. Key correlates to
* the index of the cipher in the ciphers list. Value correlates to the index of the folder in the
* folders list.
*/
@Serializable
data class ImportCiphersJsonRequest(
@SerialName("folders")
val folders: List<FolderWithIdJsonRequest>,
@SerialName("ciphers")
val ciphers: List<CipherJsonRequest>,
@SerialName("folderRelationships")
val folderRelationships: Map<Int, Int>,
) {
/**
* Represents a folder request with an optional [id] if the folder already exists.
*
* @property name The name of the folder.
* @property id The ID of the folder, if it already exists. Null otherwise.
**/
@Serializable
data class FolderWithIdJsonRequest(
@SerialName("name")
val name: String?,
@SerialName("id")
val id: String?,
)
}

View file

@ -0,0 +1,41 @@
package com.x8bit.bitwarden.data.vault.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* The response body for importing ciphers.
*/
@Serializable
sealed class ImportCiphersResponseJson {
/**
* Models a successful json response.
*/
@Serializable
object Success : ImportCiphersResponseJson()
/**
* Represents the json body of an invalid request.
*
* @param validationErrors a map where each value is a list of error messages for each key.
* The values in the array should be used for display to the user, since the keys tend to come
* back as nonsense. (eg: empty string key)
*/
@Serializable
data class Invalid(
@SerialName("message")
private val invalidMessage: String? = null,
@SerialName("Message")
private val errorMessage: String? = null,
@SerialName("validationErrors")
val validationErrors: Map<String, List<String>>?,
) : ImportCiphersResponseJson() {
/**
* A generic error message.
*/
val message: String? get() = invalidMessage ?: errorMessage
}
}

View file

@ -5,6 +5,8 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonReq
import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonResponse
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
@ -118,4 +120,9 @@ interface CiphersService {
* Returns a boolean indicating if the active user has unassigned ciphers.
*/
suspend fun hasUnassignedCiphers(): Result<Boolean>
/**
* Attempt to import ciphers.
*/
suspend fun importCiphers(request: ImportCiphersJsonRequest): Result<ImportCiphersResponseJson>
}

View file

@ -13,6 +13,8 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonRes
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.FileUploadType
import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
@ -216,6 +218,23 @@ class CiphersServiceImpl(
.hasUnassignedCiphers()
.toResult()
override suspend fun importCiphers(
request: ImportCiphersJsonRequest,
): Result<ImportCiphersResponseJson> =
ciphersApi
.importCiphers(body = request)
.toResult()
.map { ImportCiphersResponseJson.Success }
.recoverCatching { throwable ->
throwable
.toBitwardenError()
.parseErrorBodyOrNull<ImportCiphersResponseJson.Invalid>(
code = 400,
json = json,
)
?: throw throwable
}
private fun createMultipartBodyBuilder(
encryptedFile: File,
filename: String?,

View file

@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.api.AzureApi
import com.x8bit.bitwarden.data.vault.datasource.network.api.CiphersApi
import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.FileUploadType
import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
@ -321,6 +322,32 @@ class CiphersServiceTest : BaseServiceTest() {
val result = ciphersService.hasUnassignedCiphers()
assertTrue(result.getOrThrow())
}
@Test
fun `importCiphers should return the correct response`() = runTest {
server.enqueue(MockResponse().setBody(""))
val result = ciphersService.importCiphers(
request = ImportCiphersJsonRequest(
ciphers = listOf(createMockCipherJsonRequest(number = 1)),
folders = emptyList(),
folderRelationships = emptyMap(),
),
)
assertEquals(Unit, result.getOrThrow())
}
@Test
fun `importCiphers should return an error when the response is an error`() = runTest {
server.enqueue(MockResponse().setResponseCode(400))
val result = ciphersService.importCiphers(
request = ImportCiphersJsonRequest(
ciphers = listOf(createMockCipherJsonRequest(number = 1)),
folders = emptyList(),
folderRelationships = emptyMap(),
),
)
assertTrue(result.isFailure)
}
}
private fun setupMockUri(