mirror of
https://github.com/bitwarden/android.git
synced 2026-04-29 04:18:52 -05:00
[PM-20195] Migrate SendsService to network module (#5055)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
"""
|
||||
Reference in New Issue
Block a user