[PM-20195] Migrate SendsService to network module (#5055)

This commit is contained in:
Patrick Honkonen
2025-04-17 09:54:30 -04:00
committed by GitHub
parent 4f65044179
commit 0d40d1e569
11 changed files with 27 additions and 61 deletions

View File

@@ -0,0 +1,36 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents a response from create file send.
*/
sealed class CreateFileSendResponse {
/**
* Represents the response from a successful create file send request.
*
* @property createFileJsonResponse Response JSON received from create file send request.
*/
data class Success(
val createFileJsonResponse: CreateFileSendResponseJson,
) : CreateFileSendResponse()
/**
* Represents the json body of an invalid create request.
*
* @property message A general, user-displayable error message.
* @property 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")
override val message: String,
@SerialName("validationErrors")
override val validationErrors: Map<String, List<String>>?,
) : CreateFileSendResponse(), InvalidJsonResponse
}

View File

@@ -0,0 +1,34 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models a response from either "create text send" or "create file send" requests.
*/
sealed class CreateSendJsonResponse {
/**
* Represents a successful response from either "create text send" or "create file send"
* request.
*
* @property send The created send object.
*/
data class Success(val send: SyncResponseJson.Send) : CreateSendJsonResponse()
/**
* Represents the json body of an invalid create request.
*
* @property message A general, user-displayable error message.
* @property 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")
override val message: String,
@SerialName("validationErrors")
override val validationErrors: Map<String, List<String>>?,
) : CreateSendJsonResponse(), InvalidJsonResponse
}

View File

@@ -0,0 +1,33 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models the response from the update send request.
*/
sealed class UpdateSendResponseJson {
/**
* The request completed successfully and returned the updated [send].
*/
data class Success(
val send: SyncResponseJson.Send,
) : UpdateSendResponseJson()
/**
* Represents the json body of an invalid update request.
*
* @param message A general, user-displayable error message.
* @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")
val message: String?,
@SerialName("validationErrors")
val validationErrors: Map<String, List<String>>?,
) : UpdateSendResponseJson()
}

View File

@@ -0,0 +1,63 @@
package com.bitwarden.network.service
import com.bitwarden.network.model.CreateFileSendResponse
import com.bitwarden.network.model.CreateFileSendResponseJson
import com.bitwarden.network.model.CreateSendJsonResponse
import com.bitwarden.network.model.SendJsonRequest
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.UpdateSendResponseJson
import java.io.File
/**
* Provides an API for querying sends endpoints.
*/
interface SendsService {
/**
* Attempt to create a text send.
*/
suspend fun createTextSend(
body: SendJsonRequest,
): Result<CreateSendJsonResponse>
/**
* Attempt to create a file send.
*/
suspend fun createFileSend(
body: SendJsonRequest,
): Result<CreateFileSendResponse>
/**
* Attempt to upload the given [encryptedFile] associated with the [sendFileResponse].
*/
suspend fun uploadFile(
sendFileResponse: CreateFileSendResponseJson,
encryptedFile: File,
): Result<SyncResponseJson.Send>
/**
* Attempt to update a send.
*/
suspend fun updateSend(
sendId: String,
body: SendJsonRequest,
): Result<UpdateSendResponseJson>
/**
* Attempt to delete a send.
*/
suspend fun deleteSend(
sendId: String,
): Result<Unit>
/**
* Attempt to remove password protection from a send.
*/
suspend fun removeSendPassword(
sendId: String,
): Result<UpdateSendResponseJson>
/**
* Attempt to retrieve a send.
*/
suspend fun getSend(sendId: String): Result<SyncResponseJson.Send>
}

View File

@@ -0,0 +1,159 @@
package com.bitwarden.network.service
import androidx.core.net.toUri
import com.bitwarden.network.api.AzureApi
import com.bitwarden.network.api.SendsApi
import com.bitwarden.network.model.CreateFileSendResponse
import com.bitwarden.network.model.CreateFileSendResponseJson
import com.bitwarden.network.model.CreateSendJsonResponse
import com.bitwarden.network.model.FileUploadType
import com.bitwarden.network.model.SendJsonRequest
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.UpdateSendResponseJson
import com.bitwarden.network.model.toBitwardenError
import com.bitwarden.network.util.NetworkErrorCode
import com.bitwarden.network.util.parseErrorBodyOrNull
import com.bitwarden.network.util.toResult
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import java.time.Clock
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
/**
* Default implementation of the [SendsService].
*/
class SendsServiceImpl(
private val azureApi: AzureApi,
private val sendsApi: SendsApi,
private val clock: Clock,
private val json: Json,
) : SendsService {
override suspend fun createTextSend(
body: SendJsonRequest,
): Result<CreateSendJsonResponse> =
sendsApi
.createTextSend(body = body)
.toResult()
.map { CreateSendJsonResponse.Success(send = it) }
.recoverCatching { throwable ->
throwable.toBitwardenError()
.parseErrorBodyOrNull<CreateSendJsonResponse.Invalid>(
code = NetworkErrorCode.BAD_REQUEST,
json = json,
)
?: throw throwable
}
override suspend fun createFileSend(
body: SendJsonRequest,
): Result<CreateFileSendResponse> =
sendsApi
.createFileSend(body = body)
.toResult()
.map { CreateFileSendResponse.Success(it) }
.recoverCatching { throwable ->
throwable.toBitwardenError()
.parseErrorBodyOrNull<CreateFileSendResponse.Invalid>(
code = NetworkErrorCode.BAD_REQUEST,
json = json,
)
?: throw throwable
}
override suspend fun updateSend(
sendId: String,
body: SendJsonRequest,
): Result<UpdateSendResponseJson> =
sendsApi
.updateSend(
sendId = sendId,
body = body,
)
.toResult()
.map { UpdateSendResponseJson.Success(send = it) }
.recoverCatching { throwable ->
throwable
.toBitwardenError()
.parseErrorBodyOrNull<UpdateSendResponseJson.Invalid>(
code = NetworkErrorCode.BAD_REQUEST,
json = json,
)
?: throw throwable
}
override suspend fun uploadFile(
sendFileResponse: CreateFileSendResponseJson,
encryptedFile: File,
): Result<SyncResponseJson.Send> {
val send = sendFileResponse.sendResponse
return when (sendFileResponse.fileUploadType) {
FileUploadType.DIRECT -> {
sendsApi.uploadFile(
sendId = requireNotNull(send.id),
fileId = requireNotNull(send.file?.id),
body = MultipartBody
.Builder(
boundary = "--BWMobileFormBoundary${clock.instant().toEpochMilli()}",
)
.setType(type = MultipartBody.FORM)
.addPart(
part = MultipartBody.Part.createFormData(
body = encryptedFile.asRequestBody(
contentType = "application/octet-stream".toMediaType(),
),
name = "data",
filename = send.file.fileName,
),
)
.build(),
)
}
FileUploadType.AZURE -> {
azureApi.uploadAzureBlob(
url = sendFileResponse.url,
date = DateTimeFormatter
.RFC_1123_DATE_TIME
.format(ZonedDateTime.ofInstant(clock.instant(), ZoneOffset.UTC)),
version = sendFileResponse.url.toUri().getQueryParameter("sv"),
body = encryptedFile.asRequestBody(),
)
}
}
.toResult()
.onFailure { sendsApi.deleteSend(send.id) }
.map { send }
}
override suspend fun deleteSend(sendId: String): Result<Unit> =
sendsApi
.deleteSend(sendId = sendId)
.toResult()
override suspend fun removeSendPassword(sendId: String): Result<UpdateSendResponseJson> =
sendsApi
.removeSendPassword(sendId = sendId)
.toResult()
.map { UpdateSendResponseJson.Success(send = it) }
.recoverCatching { throwable ->
throwable
.toBitwardenError()
.parseErrorBodyOrNull<UpdateSendResponseJson.Invalid>(
code = NetworkErrorCode.BAD_REQUEST,
json = json,
)
?: throw throwable
}
override suspend fun getSend(
sendId: String,
): Result<SyncResponseJson.Send> =
sendsApi
.getSend(sendId = sendId)
.toResult()
}

View File

@@ -0,0 +1,271 @@
package com.bitwarden.network.service
import android.net.Uri
import com.bitwarden.network.api.AzureApi
import com.bitwarden.network.api.SendsApi
import com.bitwarden.network.base.BaseServiceTest
import com.bitwarden.network.model.CreateFileSendResponse
import com.bitwarden.network.model.CreateSendJsonResponse
import com.bitwarden.network.model.SendTypeJson
import com.bitwarden.network.model.UpdateSendResponseJson
import com.bitwarden.network.model.createMockFileSendResponseJson
import com.bitwarden.network.model.createMockSend
import com.bitwarden.network.model.createMockSendJsonRequest
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import retrofit2.create
import java.io.File
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class SendsServiceTest : BaseServiceTest() {
private val clock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val azureApi: AzureApi = retrofit.create()
private val sendsApi: SendsApi = retrofit.create()
private val sendsService: SendsService = SendsServiceImpl(
azureApi = azureApi,
sendsApi = sendsApi,
json = json,
clock = clock,
)
@BeforeEach
fun setup() {
mockkStatic(Uri::class)
}
@AfterEach
fun tearDown() {
unmockkStatic(Uri::class)
}
@Test
fun `createFileSend should return the correct response`() = runTest {
val sendFileResponse = CreateFileSendResponse.Success(
createFileJsonResponse = createMockFileSendResponseJson(number = 1),
)
server.enqueue(MockResponse().setBody(CREATE_FILE_SEND_SUCCESS_JSON))
val result = sendsService.createFileSend(
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.FILE),
)
assertEquals(
sendFileResponse,
result.getOrThrow(),
)
}
@Test
fun `createTextSend should return the correct response`() = runTest {
server.enqueue(MockResponse().setBody(CREATE_UPDATE_SEND_SUCCESS_JSON))
val result = sendsService.createTextSend(
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT),
)
assertEquals(
CreateSendJsonResponse.Success(createMockSend(number = 1)),
result.getOrThrow(),
)
}
@Test
fun `updateSend with success response should return a Success with the correct send`() =
runTest {
server.enqueue(MockResponse().setBody(CREATE_UPDATE_SEND_SUCCESS_JSON))
val result = sendsService.updateSend(
sendId = "send-id-1",
body = createMockSendJsonRequest(number = 1),
)
assertEquals(
UpdateSendResponseJson.Success(
send = createMockSend(number = 1),
),
result.getOrThrow(),
)
}
@Test
fun `updateSend with an invalid response should return an Invalid with the correct data`() =
runTest {
server.enqueue(MockResponse().setResponseCode(400).setBody(UPDATE_SEND_INVALID_JSON))
val result = sendsService.updateSend(
sendId = "send-id-1",
body = createMockSendJsonRequest(number = 1),
)
assertEquals(
UpdateSendResponseJson.Invalid(
message = "You do not have permission to edit this.",
validationErrors = null,
),
result.getOrThrow(),
)
}
@Test
fun `uploadFile with Azure uploadFile success should return send`() = runTest {
val url = "www.test.com"
setupMockUri(url = url, queryParams = mapOf("sv" to "2024-04-03"))
val sendFileResponse = createMockFileSendResponseJson(number = 1)
val encryptedFile = File.createTempFile("mockFile", "temp")
server.enqueue(MockResponse().setResponseCode(201))
val result = sendsService.uploadFile(
sendFileResponse = sendFileResponse,
encryptedFile = encryptedFile,
)
assertEquals(sendFileResponse.sendResponse, result.getOrThrow())
}
@Test
fun `uploadFile with Direct uploadFile success should return send`() = runTest {
val url = "www.test.com"
setupMockUri(url = url, queryParams = mapOf("sv" to "2024-04-03"))
val sendFileResponse = createMockFileSendResponseJson(number = 1)
val encryptedFile = File.createTempFile("mockFile", "temp")
server.enqueue(MockResponse().setResponseCode(201))
val result = sendsService.uploadFile(
sendFileResponse = sendFileResponse,
encryptedFile = encryptedFile,
)
assertEquals(sendFileResponse.sendResponse, result.getOrThrow())
}
@Test
fun `deleteSend should return a Success with the correct data`() = runTest {
server.enqueue(MockResponse().setResponseCode(200))
val result = sendsService.deleteSend(sendId = "send-id-1")
assertEquals(Unit, result.getOrThrow())
}
@Test
fun `removeSendPassword with success response should return a Success with the correct send`() =
runTest {
server.enqueue(MockResponse().setBody(CREATE_UPDATE_SEND_SUCCESS_JSON))
val result = sendsService.removeSendPassword(sendId = "send-id-1")
assertEquals(
UpdateSendResponseJson.Success(send = createMockSend(number = 1)),
result.getOrThrow(),
)
}
@Suppress("MaxLineLength")
@Test
fun `removeSendPassword with an invalid response should return an Invalid with the correct data`() =
runTest {
server.enqueue(MockResponse().setResponseCode(400).setBody(UPDATE_SEND_INVALID_JSON))
val result = sendsService.removeSendPassword(sendId = "send-id-1")
assertEquals(
UpdateSendResponseJson.Invalid(
message = "You do not have permission to edit this.",
validationErrors = null,
),
result.getOrThrow(),
)
}
@Test
fun `getSend should return the correct response`() = runTest {
val response = createMockSend(number = 1)
server.enqueue(MockResponse().setBody(CREATE_UPDATE_SEND_SUCCESS_JSON))
val result = sendsService.getSend("mockId-1")
assertEquals(response, result.getOrThrow())
}
private fun setupMockUri(
url: String,
queryParams: Map<String, String>,
): Uri {
val mockUri = mockk<Uri> {
queryParams.forEach {
every { getQueryParameter(it.key) } returns it.value
}
}
every { Uri.parse(url) } returns mockUri
return mockUri
}
}
private const val CREATE_UPDATE_SEND_SUCCESS_JSON = """
{
"id": "mockId-1",
"accessId": "mockAccessId-1",
"type": 1,
"name": "mockName-1",
"notes": "mockNotes-1",
"file": {
"id": "mockId-1",
"fileName": "mockFileName-1",
"size": 1,
"sizeName": "mockSizeName-1"
},
"text": {
"text": "mockText-1",
"hidden": false
},
"key": "mockKey-1",
"maxAccessCount": 1,
"accessCount": 1,
"password": "mockPassword-1",
"disabled": false,
"revisionDate": "2023-10-27T12:00:00.00Z",
"expirationDate": "2023-10-27T12:00:00.00Z",
"deletionDate": "2023-10-27T12:00:00.00Z",
"hideEmail": false
}
"""
private const val CREATE_FILE_SEND_SUCCESS_JSON = """
{
"url": "www.test.com",
"fileUploadType": "1",
"sendResponse": {
"id": "mockId-1",
"accessId": "mockAccessId-1",
"type": 1,
"name": "mockName-1",
"notes": "mockNotes-1",
"file": {
"id": "mockId-1",
"fileName": "mockFileName-1",
"size": 1,
"sizeName": "mockSizeName-1"
},
"text": {
"text": "mockText-1",
"hidden": false
},
"key": "mockKey-1",
"maxAccessCount": 1,
"accessCount": 1,
"password": "mockPassword-1",
"disabled": false,
"revisionDate": "2023-10-27T12:00:00.00Z",
"expirationDate": "2023-10-27T12:00:00.00Z",
"deletionDate": "2023-10-27T12:00:00.00Z",
"hideEmail": false
}
}
"""
private const val UPDATE_SEND_INVALID_JSON = """
{
"message": "You do not have permission to edit this.",
"validationErrors": null
}
"""