PM-29824: Add bulk share ciphers network layer implementation (#6271)

This commit is contained in:
Patrick Honkonen
2025-12-16 09:12:33 -05:00
committed by GitHub
parent ef6714fa17
commit 7f032a8732
7 changed files with 183 additions and 0 deletions

View File

@@ -2,7 +2,9 @@ package com.bitwarden.network.api
import com.bitwarden.network.model.AttachmentJsonRequest
import com.bitwarden.network.model.AttachmentJsonResponse
import com.bitwarden.network.model.BulkShareCiphersJsonRequest
import com.bitwarden.network.model.CipherJsonRequest
import com.bitwarden.network.model.CipherMiniResponseJson
import com.bitwarden.network.model.CreateCipherInOrganizationJsonRequest
import com.bitwarden.network.model.ImportCiphersJsonRequest
import com.bitwarden.network.model.NetworkResult
@@ -75,6 +77,14 @@ internal interface CiphersApi {
@Body body: ShareCipherJsonRequest,
): NetworkResult<SyncResponseJson.Cipher>
/**
* Shares multiple ciphers in bulk.
*/
@PUT("ciphers/share")
suspend fun bulkShareCiphers(
@Body body: BulkShareCiphersJsonRequest,
): NetworkResult<List<CipherMiniResponseJson>>
/**
* Shares an attachment.
*/

View File

@@ -0,0 +1,19 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents a bulk share ciphers request.
*
* @property ciphers The list of ciphers to share.
* @property collectionIds A list of collection IDs to associate with all ciphers.
*/
@Serializable
data class BulkShareCiphersJsonRequest(
@SerialName("Ciphers")
val ciphers: List<CipherJsonRequest>,
@SerialName("CollectionIds")
val collectionIds: List<String>,
)

View File

@@ -0,0 +1,66 @@
package com.bitwarden.network.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.ZonedDateTime
/**
* Represents a minimal cipher response from the API, typically returned from bulk operations.
* Contains core cipher metadata without detailed type-specific fields.
*
* @property id The ID of the cipher.
* @property organizationId The organization ID (nullable).
* @property type The type of cipher.
* @property data Serialized cipher data (newer API format).
* @property attachments List of attachments (nullable).
* @property shouldOrganizationUseTotp If the organization should use TOTP.
* @property revisionDate The revision date.
* @property creationDate The creation date.
* @property deletedDate The deleted date (nullable).
* @property reprompt The reprompt type.
* @property key The cipher key (nullable).
* @property archivedDate The archived date (nullable).
*/
@Serializable
data class CipherMiniResponseJson(
@SerialName("id")
val id: String,
@SerialName("organizationId")
val organizationId: String?,
@SerialName("type")
val type: CipherTypeJson,
@SerialName("data")
val data: String?,
@SerialName("attachments")
val attachments: List<SyncResponseJson.Cipher.Attachment>?,
@SerialName("organizationUseTotp")
val shouldOrganizationUseTotp: Boolean,
@SerialName("revisionDate")
@Contextual
val revisionDate: ZonedDateTime,
@SerialName("creationDate")
@Contextual
val creationDate: ZonedDateTime,
@SerialName("deletedDate")
@Contextual
val deletedDate: ZonedDateTime?,
@SerialName("reprompt")
val reprompt: CipherRepromptTypeJson,
@SerialName("key")
val key: String?,
@SerialName("archivedDate")
@Contextual
val archivedDate: ZonedDateTime?,
)

View File

@@ -3,7 +3,9 @@ package com.bitwarden.network.service
import com.bitwarden.network.model.AttachmentInfo
import com.bitwarden.network.model.AttachmentJsonRequest
import com.bitwarden.network.model.AttachmentJsonResponse
import com.bitwarden.network.model.BulkShareCiphersJsonRequest
import com.bitwarden.network.model.CipherJsonRequest
import com.bitwarden.network.model.CipherMiniResponseJson
import com.bitwarden.network.model.CreateCipherInOrganizationJsonRequest
import com.bitwarden.network.model.CreateCipherResponseJson
import com.bitwarden.network.model.ImportCiphersJsonRequest
@@ -63,6 +65,13 @@ interface CiphersService {
body: ShareCipherJsonRequest,
): Result<SyncResponseJson.Cipher>
/**
* Attempt to share multiple ciphers in bulk.
*/
suspend fun bulkShareCiphers(
body: BulkShareCiphersJsonRequest,
): Result<List<CipherMiniResponseJson>>
/**
* Attempt to share an attachment.
*/

View File

@@ -6,7 +6,9 @@ import com.bitwarden.network.api.CiphersApi
import com.bitwarden.network.model.AttachmentInfo
import com.bitwarden.network.model.AttachmentJsonRequest
import com.bitwarden.network.model.AttachmentJsonResponse
import com.bitwarden.network.model.BulkShareCiphersJsonRequest
import com.bitwarden.network.model.CipherJsonRequest
import com.bitwarden.network.model.CipherMiniResponseJson
import com.bitwarden.network.model.CreateCipherInOrganizationJsonRequest
import com.bitwarden.network.model.CreateCipherResponseJson
import com.bitwarden.network.model.FileUploadType
@@ -185,6 +187,13 @@ internal class CiphersServiceImpl(
)
.toResult()
override suspend fun bulkShareCiphers(
body: BulkShareCiphersJsonRequest,
): Result<List<CipherMiniResponseJson>> =
ciphersApi
.bulkShareCiphers(body = body)
.toResult()
override suspend fun updateCipherCollections(
cipherId: String,
body: UpdateCipherCollectionsJsonRequest,

View File

@@ -0,0 +1,23 @@
package com.bitwarden.network.model
import java.time.ZonedDateTime
/**
* Create a mock [CipherMiniResponseJson] for testing.
*/
fun createMockCipherMiniResponse(
number: Int,
): CipherMiniResponseJson = CipherMiniResponseJson(
id = "mockId-$number",
organizationId = "mockOrgId-$number",
type = CipherTypeJson.LOGIN,
data = "mockData-$number",
attachments = null,
shouldOrganizationUseTotp = false,
revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"),
creationDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"),
deletedDate = null,
reprompt = CipherRepromptTypeJson.NONE,
key = "mockKey-$number",
archivedDate = null,
)

View File

@@ -5,6 +5,8 @@ import com.bitwarden.network.api.AzureApi
import com.bitwarden.network.api.CiphersApi
import com.bitwarden.network.base.BaseServiceTest
import com.bitwarden.network.model.AttachmentJsonResponse
import com.bitwarden.network.model.BulkShareCiphersJsonRequest
import com.bitwarden.network.model.CipherMiniResponseJson
import com.bitwarden.network.model.CreateCipherInOrganizationJsonRequest
import com.bitwarden.network.model.CreateCipherResponseJson
import com.bitwarden.network.model.FileUploadType
@@ -19,11 +21,13 @@ import com.bitwarden.network.model.createMockAttachmentJsonRequest
import com.bitwarden.network.model.createMockAttachmentResponse
import com.bitwarden.network.model.createMockCipher
import com.bitwarden.network.model.createMockCipherJsonRequest
import com.bitwarden.network.model.createMockCipherMiniResponse
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.encodeToString
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
@@ -289,6 +293,49 @@ class CiphersServiceTest : BaseServiceTest() {
)
}
@Test
fun `bulkShareCiphers with success response should return Success`() = runTest {
val expectedCiphers = listOf(
createMockCipherMiniResponse(number = 1),
createMockCipherMiniResponse(number = 2),
)
server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(json.encodeToString<List<CipherMiniResponseJson>>(expectedCiphers)),
)
val result = ciphersService.bulkShareCiphers(
body = BulkShareCiphersJsonRequest(
ciphers = listOf(
createMockCipherJsonRequest(number = 1),
createMockCipherJsonRequest(number = 2),
),
collectionIds = listOf("mockId-1"),
),
)
assertEquals(expectedCiphers, result.getOrThrow())
}
@Test
fun `bulkShareCiphers with failure response should return Failure`() = runTest {
server.enqueue(
MockResponse()
.setResponseCode(500)
.setBody("""{"message":"Server error"}"""),
)
val result = ciphersService.bulkShareCiphers(
body = BulkShareCiphersJsonRequest(
ciphers = listOf(createMockCipherJsonRequest(number = 1)),
collectionIds = listOf("mockId-1"),
),
)
assertTrue(result.isFailure)
}
@Test
fun `updateCipherCollections should execute the updateCipherCollections API`() = runTest {
server.enqueue(MockResponse().setResponseCode(200))