diff --git a/network/src/main/kotlin/com/bitwarden/network/api/CiphersApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/CiphersApi.kt index 09cb922509..17f666b8b6 100644 --- a/network/src/main/kotlin/com/bitwarden/network/api/CiphersApi.kt +++ b/network/src/main/kotlin/com/bitwarden/network/api/CiphersApi.kt @@ -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 + /** + * Shares multiple ciphers in bulk. + */ + @PUT("ciphers/share") + suspend fun bulkShareCiphers( + @Body body: BulkShareCiphersJsonRequest, + ): NetworkResult> + /** * Shares an attachment. */ diff --git a/network/src/main/kotlin/com/bitwarden/network/model/BulkShareCiphersJsonRequest.kt b/network/src/main/kotlin/com/bitwarden/network/model/BulkShareCiphersJsonRequest.kt new file mode 100644 index 0000000000..e66e925aa4 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/BulkShareCiphersJsonRequest.kt @@ -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, + + @SerialName("CollectionIds") + val collectionIds: List, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/model/CipherMiniResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/CipherMiniResponseJson.kt new file mode 100644 index 0000000000..b19866c716 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/CipherMiniResponseJson.kt @@ -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?, + + @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?, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/service/CiphersService.kt b/network/src/main/kotlin/com/bitwarden/network/service/CiphersService.kt index fb93efe89b..34f99de9e4 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/CiphersService.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/CiphersService.kt @@ -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 + /** + * Attempt to share multiple ciphers in bulk. + */ + suspend fun bulkShareCiphers( + body: BulkShareCiphersJsonRequest, + ): Result> + /** * Attempt to share an attachment. */ diff --git a/network/src/main/kotlin/com/bitwarden/network/service/CiphersServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/CiphersServiceImpl.kt index 371d4b2d4f..bba1e352df 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/CiphersServiceImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/CiphersServiceImpl.kt @@ -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> = + ciphersApi + .bulkShareCiphers(body = body) + .toResult() + override suspend fun updateCipherCollections( cipherId: String, body: UpdateCipherCollectionsJsonRequest, diff --git a/network/src/test/kotlin/com/bitwarden/network/model/CipherMiniResponseJsonUtil.kt b/network/src/test/kotlin/com/bitwarden/network/model/CipherMiniResponseJsonUtil.kt new file mode 100644 index 0000000000..e46a8d7823 --- /dev/null +++ b/network/src/test/kotlin/com/bitwarden/network/model/CipherMiniResponseJsonUtil.kt @@ -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, +) diff --git a/network/src/test/kotlin/com/bitwarden/network/service/CiphersServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/CiphersServiceTest.kt index 2c914ac571..e3aa7130b0 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/CiphersServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/CiphersServiceTest.kt @@ -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>(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))