Add underlying support for file sends (#646)

This commit is contained in:
David Perez 2024-01-17 14:41:18 -06:00 committed by Álison Fernandes
parent dbbb9f6587
commit cd236f183f
24 changed files with 676 additions and 46 deletions

View file

@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.vault.datasource.network.api
import okhttp3.RequestBody
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.PUT
import retrofit2.http.Url
/**
* Defines raw calls to the Azure API without any authentication applied.
*/
interface AzureApi {
/**
* Attempts to upload an encrypted file to Azure.
*/
@PUT
@Headers("x-ms-blob-type: BlockBlob")
suspend fun uploadAzureBlob(
@Url url: String,
@Header("x-ms-date") date: String,
@Header("x-ms-version") version: String?,
@Body body: RequestBody,
): Result<Unit>
}

View file

@ -1,8 +1,10 @@
package com.x8bit.bitwarden.data.vault.datasource.network.api
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendFileResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.POST
@ -21,6 +23,12 @@ interface SendsApi {
@POST("sends")
suspend fun createSend(@Body body: SendJsonRequest): Result<SyncResponseJson.Send>
/**
* Create a file send.
*/
@POST("sends/file/v2")
suspend fun createFileSend(@Body body: SendJsonRequest): Result<SendFileResponseJson>
/**
* Updates a send.
*/
@ -30,6 +38,16 @@ interface SendsApi {
@Body body: SendJsonRequest,
): Result<SyncResponseJson.Send>
/**
* Uploads the file associated with a send.
*/
@POST("sends/{sendId}/file/{fileId}")
suspend fun uploadFile(
@Path("sendId") sendId: String,
@Path("fileId") fileId: String,
@Body body: MultipartBody,
): Result<Unit>
/**
* Deletes a send.
*/

View file

@ -13,6 +13,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import retrofit2.create
import java.time.Clock
import javax.inject.Singleton
/**
@ -37,9 +38,17 @@ object VaultNetworkModule {
fun provideSendsService(
retrofits: Retrofits,
json: Json,
clock: Clock,
): SendsService = SendsServiceImpl(
azureApi = retrofits
.staticRetrofitBuilder
// This URL will be overridden dynamically
.baseUrl("https://www.bitwaredn.com")
.build()
.create(),
sendsApi = retrofits.authenticatedApiRetrofit.create(),
json = json,
clock = clock,
)
@Provides

View file

@ -0,0 +1,39 @@
package com.x8bit.bitwarden.data.vault.datasource.network.model
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.BaseEnumeratedIntSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the JSON response from creating a new file send.
*/
@Serializable
data class SendFileResponseJson(
@SerialName("url")
val url: String,
@SerialName("fileUploadType")
val fileUploadType: FileUploadType,
@SerialName("sendResponse")
val sendResponse: SyncResponseJson.Send,
) {
/**
* Represents the type of file upload that should be used.
*/
@Serializable(FileUploadTypeSerializer::class)
enum class FileUploadType {
@SerialName("0")
DIRECT,
@SerialName("1")
AZURE,
}
}
@Keep
private class FileUploadTypeSerializer :
BaseEnumeratedIntSerializer<SendFileResponseJson.FileUploadType>(
SendFileResponseJson.FileUploadType.entries.toTypedArray(),
)

View file

@ -16,6 +16,7 @@ import java.time.ZonedDateTime
* @property expirationDate The date in which the send will expire (nullable).
* @property deletionDate The date in which the send will be deleted.
* @property file The file associated with this send (nullable).
* @property fileLength The length of the file in bytes (nullable).
* @property text The text associated with this send (nullable).
* @property password The password protecting this send (nullable).
* @property isDisabled Indicate if this send is disabled.
@ -46,6 +47,9 @@ data class SendJsonRequest(
@Contextual
val deletionDate: ZonedDateTime,
@SerialName("fileLength")
val fileLength: Int?,
@SerialName("file")
val file: SyncResponseJson.Send.File?,

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.vault.datasource.network.service
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendFileResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateSendResponseJson
@ -15,6 +16,21 @@ interface SendsService {
body: SendJsonRequest,
): Result<SyncResponseJson.Send>
/**
* Attempt to create a file send.
*/
suspend fun createFileSend(
body: SendJsonRequest,
): Result<SendFileResponseJson>
/**
* Attempt to upload the given [encryptedFile] associated with the [sendFileResponse].
*/
suspend fun uploadFile(
sendFileResponse: SendFileResponseJson,
encryptedFile: ByteArray,
): Result<SyncResponseJson.Send>
/**
* Attempt to update a send.
*/

View file

@ -1,23 +1,38 @@
package com.x8bit.bitwarden.data.vault.datasource.network.service
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull
import com.x8bit.bitwarden.data.vault.datasource.network.api.AzureApi
import com.x8bit.bitwarden.data.vault.datasource.network.api.SendsApi
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendFileResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateSendResponseJson
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
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 createSend(body: SendJsonRequest): Result<SyncResponseJson.Send> =
sendsApi.createSend(body = body)
override suspend fun createFileSend(body: SendJsonRequest): Result<SendFileResponseJson> =
sendsApi.createFileSend(body = body)
override suspend fun updateSend(
sendId: String,
body: SendJsonRequest,
@ -38,6 +53,48 @@ class SendsServiceImpl(
?: throw throwable
}
override suspend fun uploadFile(
sendFileResponse: SendFileResponseJson,
encryptedFile: ByteArray,
): Result<SyncResponseJson.Send> {
val send = sendFileResponse.sendResponse
return when (sendFileResponse.fileUploadType) {
SendFileResponseJson.FileUploadType.DIRECT -> {
sendsApi.uploadFile(
sendId = requireNotNull(send.id),
fileId = requireNotNull(send.file?.id),
body = MultipartBody
.Builder(
boundary = "--BWMobileFormBoundary${clock.instant().toEpochMilli()}",
)
.addPart(
part = MultipartBody.Part.createFormData(
body = encryptedFile.toRequestBody(
contentType = "application/octet-stream".toMediaType(),
),
name = "data",
filename = send.file?.fileName,
),
)
.build(),
)
}
SendFileResponseJson.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.toRequestBody(),
)
}
}
.onFailure { sendsApi.deleteSend(send.id) }
.map { send }
}
override suspend fun deleteSend(sendId: String): Result<Unit> =
sendsApi.deleteSend(sendId = sendId)

View file

@ -169,6 +169,19 @@ interface VaultSdkSource {
sendView: SendView,
): Result<Send>
/**
* Encrypts a [ByteArray] file buffer for the user with the given [userId], returning an
* encrypted [ByteArray] wrapped in a [Result].
*
* This should only be called after a successful call to [initializeCrypto] for the associated
* user.
*/
suspend fun encryptBuffer(
userId: String,
send: Send,
fileBuffer: ByteArray,
): Result<ByteArray>
/**
* Decrypts a [Send] for the user with the given [userId], returning a [SendView] wrapped in a
* [Result].

View file

@ -104,6 +104,21 @@ class VaultSdkSourceImpl(
.encrypt(sendView)
}
override suspend fun encryptBuffer(
userId: String,
send: Send,
fileBuffer: ByteArray,
): Result<ByteArray> =
runCatching {
getClient(userId = userId)
.vault()
.sends()
.encryptBuffer(
send = send,
buffer = fileBuffer,
)
}
override suspend fun encryptCipher(
userId: String,
cipherView: CipherView,

View file

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.vault.manager
import android.net.Uri
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* Manages reading files.
*/
@OmitFromCoverage
interface FileManager {
/**
* Reads the [fileUri] into memory and returns the raw [ByteArray]
*/
fun uriToByteArray(fileUri: Uri): ByteArray
}

View file

@ -0,0 +1,36 @@
package com.x8bit.bitwarden.data.vault.manager
import android.content.Context
import android.net.Uri
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import java.io.ByteArrayOutputStream
/**
* The buffer size to be used when reading from an input stream.
*/
private const val BUFFER_SIZE: Int = 1024
/**
* The default implementation of the [FileManager] interface.
*/
@OmitFromCoverage
class FileManagerImpl(
private val context: Context,
) : FileManager {
override fun uriToByteArray(fileUri: Uri): ByteArray =
context
.contentResolver
.openInputStream(fileUri)
?.use { inputStream ->
ByteArrayOutputStream().use { outputStream ->
val buffer = ByteArray(BUFFER_SIZE)
var length: Int
while (inputStream.read(buffer).also { length = it } != -1) {
outputStream.write(buffer, 0, length)
}
outputStream.toByteArray()
}
}
?: byteArrayOf()
}

View file

@ -1,16 +1,20 @@
package com.x8bit.bitwarden.data.vault.manager.di
import android.content.Context
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.FileManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@ -21,6 +25,12 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object VaultManagerModule {
@Provides
@Singleton
fun provideFileManager(
@ApplicationContext context: Context,
): FileManager = FileManagerImpl(context)
@Provides
@Singleton
fun provideVaultLockManager(

View file

@ -1,8 +1,10 @@
package com.x8bit.bitwarden.data.vault.repository
import android.net.Uri
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
import com.bitwarden.core.FolderView
import com.bitwarden.core.SendType
import com.bitwarden.core.SendView
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.platform.repository.model.DataState
@ -159,9 +161,10 @@ interface VaultRepository : VaultLockManager {
): UpdateCipherResult
/**
* Attempt to create a send.
* Attempt to create a send. The [fileUri] _must_ be present when the given [SendView] has a
* [SendView.type] of [SendType.FILE].
*/
suspend fun createSend(sendView: SendView): CreateSendResult
suspend fun createSend(sendView: SendView, fileUri: Uri?): CreateSendResult
/**
* Attempt to update a send.

View file

@ -1,10 +1,12 @@
package com.x8bit.bitwarden.data.vault.repository
import android.net.Uri
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
import com.bitwarden.core.FolderView
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.SendType
import com.bitwarden.core.SendView
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
@ -18,6 +20,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
import com.x8bit.bitwarden.data.platform.repository.util.map
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndLoggedIn
import com.x8bit.bitwarden.data.platform.repository.util.updateToPendingOrLoading
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
@ -27,6 +30,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService
import com.x8bit.bitwarden.data.vault.datasource.network.service.SendsService
import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
@ -80,6 +84,7 @@ class VaultRepositoryImpl(
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val authDiskSource: AuthDiskSource,
private val fileManager: FileManager,
private val vaultLockManager: VaultLockManager,
dispatcherManager: DispatcherManager,
) : VaultRepository,
@ -415,14 +420,50 @@ class VaultRepositoryImpl(
)
}
override suspend fun createSend(sendView: SendView): CreateSendResult {
override suspend fun createSend(
sendView: SendView,
fileUri: Uri?,
): CreateSendResult {
val userId = requireNotNull(activeUserId)
return vaultSdkSource
.encryptSend(
userId = userId,
sendView = sendView,
)
.flatMap { send -> sendsService.createSend(body = send.toEncryptedNetworkSend()) }
.flatMap { send ->
when (send.type) {
SendType.TEXT -> {
sendsService.createSend(body = send.toEncryptedNetworkSend())
}
SendType.FILE -> {
val uri = fileUri ?: return@flatMap IllegalArgumentException(
"File URI must be present to create a File Send.",
)
.asFailure()
vaultSdkSource
.encryptBuffer(
userId = userId,
send = send,
fileBuffer = fileManager.uriToByteArray(fileUri = uri),
)
.flatMap { encryptedFile ->
sendsService
.createFileSend(
body = send.toEncryptedNetworkSend(
fileLength = encryptedFile.size,
),
)
.flatMap { sendFileResponse ->
sendsService.uploadFile(
sendFileResponse = sendFileResponse,
encryptedFile = encryptedFile,
)
}
}
}
}
}
.onSuccess {
// Save the send immediately, regardless of whether the decrypt succeeds
vaultDiskSource.saveSend(userId = userId, send = it)

View file

@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService
import com.x8bit.bitwarden.data.vault.datasource.network.service.SendsService
import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepositoryImpl
@ -32,6 +33,7 @@ object VaultRepositoryModule {
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
fileManager: FileManager,
vaultLockManager: VaultLockManager,
dispatcherManager: DispatcherManager,
): VaultRepository = VaultRepositoryImpl(
@ -41,6 +43,7 @@ object VaultRepositoryModule {
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
authDiskSource = authDiskSource,
fileManager = fileManager,
vaultLockManager = vaultLockManager,
dispatcherManager = dispatcherManager,
)

View file

@ -13,7 +13,7 @@ import java.time.ZonedDateTime
/**
* Converts a Bitwarden SDK [Send] object to a corresponding [SyncResponseJson.Send] object.
*/
fun Send.toEncryptedNetworkSend(): SendJsonRequest =
fun Send.toEncryptedNetworkSend(fileLength: Int? = null): SendJsonRequest =
SendJsonRequest(
type = type.toNetworkSendType(),
name = name,
@ -22,6 +22,7 @@ fun Send.toEncryptedNetworkSend(): SendJsonRequest =
maxAccessCount = maxAccessCount?.toInt(),
expirationDate = expirationDate?.let { ZonedDateTime.ofInstant(it, ZoneOffset.UTC) },
deletionDate = ZonedDateTime.ofInstant(deletionDate, ZoneOffset.UTC),
fileLength = fileLength,
file = file?.toNetworkSendFile(),
text = text?.toNetworkSendText(),
password = password,

View file

@ -452,7 +452,10 @@ class AddSendViewModel @Inject constructor(
viewModelScope.launch {
when (val addSendType = state.addSendType) {
AddSendType.AddItem -> {
val result = vaultRepo.createSend(content.toSendView(clock))
val result = vaultRepo.createSend(
sendView = content.toSendView(clock),
fileUri = null,
)
sendAction(AddSendAction.Internal.CreateSendResultReceive(result))
}

View file

@ -17,6 +17,7 @@ fun createMockSendJsonRequest(
maxAccessCount = 1,
expirationDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
deletionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
fileLength = 1,
file = createMockFile(number),
text = createMockText(number),
password = "mockPassword-$number",

View file

@ -2,14 +2,17 @@ package com.x8bit.bitwarden.data.vault.datasource.network.model
import java.time.ZonedDateTime
fun createMockSend(number: Int): SyncResponseJson.Send =
fun createMockSend(
number: Int,
type: SendTypeJson = SendTypeJson.FILE,
): SyncResponseJson.Send =
SyncResponseJson.Send(
accessCount = 1,
notes = "mockNotes-$number",
revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
maxAccessCount = 1,
shouldHideEmail = false,
type = SendTypeJson.FILE,
type = type,
accessId = "mockAccessId-$number",
password = "mockPassword-$number",
file = createMockFile(number = number),

View file

@ -1,29 +1,73 @@
package com.x8bit.bitwarden.data.vault.datasource.network.service
import android.net.Uri
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import com.x8bit.bitwarden.data.vault.datasource.network.api.AzureApi
import com.x8bit.bitwarden.data.vault.datasource.network.api.SendsApi
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendFileResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateSendResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockSend
import com.x8bit.bitwarden.data.vault.datasource.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.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 response = SendFileResponseJson(
url = "www.test.com",
fileUploadType = SendFileResponseJson.FileUploadType.AZURE,
sendResponse = createMockSend(number = 1, type = SendTypeJson.FILE),
)
server.enqueue(MockResponse().setBody(CREATE_FILE_SEND_SUCCESS_JSON))
val result = sendsService.createFileSend(
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.FILE),
)
assertEquals(response, result.getOrThrow())
}
@Test
fun `createSend should return the correct response`() = runTest {
server.enqueue(MockResponse().setBody(CREATE_UPDATE_SEND_SUCCESS_JSON))
val result = sendsService.createSend(
body = createMockSendJsonRequest(number = 1),
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT),
)
assertEquals(
createMockSend(number = 1),
@ -64,6 +108,46 @@ class SendsServiceTest : BaseServiceTest() {
)
}
@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 mockSend = createMockSend(number = 1, type = SendTypeJson.FILE)
val sendFileResponse = SendFileResponseJson(
url = url,
fileUploadType = SendFileResponseJson.FileUploadType.AZURE,
sendResponse = mockSend,
)
val encryptedFile = byteArrayOf()
server.enqueue(MockResponse().setResponseCode(201))
val result = sendsService.uploadFile(
sendFileResponse = sendFileResponse,
encryptedFile = encryptedFile,
)
assertEquals(mockSend, result.getOrThrow())
}
@Test
fun `uploadFile with Direct uploadFile success should return send`() = runTest {
val mockSend = createMockSend(number = 1, type = SendTypeJson.FILE)
val sendFileResponse = SendFileResponseJson(
url = "www.test.com",
fileUploadType = SendFileResponseJson.FileUploadType.DIRECT,
sendResponse = mockSend,
)
val encryptedFile = byteArrayOf()
server.enqueue(MockResponse().setResponseCode(201))
val result = sendsService.uploadFile(
sendFileResponse = sendFileResponse,
encryptedFile = encryptedFile,
)
assertEquals(mockSend, result.getOrThrow())
}
@Test
fun `deleteSend should return a Success with the correct data`() = runTest {
server.enqueue(MockResponse().setResponseCode(200))
@ -96,6 +180,19 @@ class SendsServiceTest : BaseServiceTest() {
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 = """
@ -127,6 +224,39 @@ private const val CREATE_UPDATE_SEND_SUCCESS_JSON = """
}
"""
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.",

View file

@ -9,7 +9,10 @@ import java.time.ZonedDateTime
/**
* Create a mock [Send] with a given [number].
*/
fun createMockSdkSend(number: Int): Send =
fun createMockSdkSend(
number: Int,
type: SendType = SendType.FILE,
): Send =
Send(
id = "mockId-$number",
accessId = "mockAccessId-$number",
@ -17,7 +20,7 @@ fun createMockSdkSend(number: Int): Send =
notes = "mockNotes-$number",
key = "mockKey-$number",
password = "mockPassword-$number",
type = SendType.FILE,
type = type,
file = createMockSdkFile(number = number),
text = createMockSdkText(number = number),
maxAccessCount = 1u,

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.vault.repository
import android.net.Uri
import app.cash.turbine.test
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
@ -7,6 +8,7 @@ import com.bitwarden.core.FolderView
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.SendType
import com.bitwarden.core.SendView
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
@ -21,6 +23,8 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendFileResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateSendResponseJson
@ -46,6 +50,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkCollecti
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFolder
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkSend
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
@ -68,12 +73,16 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
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 java.net.UnknownHostException
@ -81,6 +90,7 @@ import java.net.UnknownHostException
class VaultRepositoryTest {
private val dispatcherManager: DispatcherManager = FakeDispatcherManager()
private val fileManager: FileManager = mockk()
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val syncService: SyncService = mockk()
private val sendsService: SendsService = mockk()
@ -112,8 +122,19 @@ class VaultRepositoryTest {
authDiskSource = fakeAuthDiskSource,
vaultLockManager = vaultLockManager,
dispatcherManager = dispatcherManager,
fileManager = fileManager,
)
@BeforeEach
fun setup() {
mockkStatic(Uri::class)
}
@AfterEach
fun tearDown() {
unmockkStatic(Uri::class)
}
@Test
fun `ciphersStateFlow should emit decrypted list of ciphers when decryptCipherList succeeds`() =
runTest {
@ -1561,51 +1582,181 @@ class VaultRepositoryTest {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns IllegalStateException().asFailure()
val result = vaultRepository.createSend(sendView = mockSendView)
val result = vaultRepository.createSend(sendView = mockSendView, fileUri = null)
assertEquals(CreateSendResult.Error, result)
}
@Test
@Suppress("MaxLineLength")
fun `createSend with sendsService createSend failure should return CreateSendResult failure`() =
fun `createSend with TEXT and sendsService createSend failure should return CreateSendResult failure`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val mockSendView = createMockSendView(number = 1)
val mockSendView = createMockSendView(number = 1, type = SendType.TEXT)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns createMockSdkSend(number = 1).asSuccess()
} returns createMockSdkSend(number = 1, type = SendType.TEXT).asSuccess()
coEvery {
sendsService.createSend(body = createMockSendJsonRequest(number = 1))
sendsService.createSend(
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT)
.copy(fileLength = null),
)
} returns IllegalStateException().asFailure()
val result = vaultRepository.createSend(sendView = mockSendView)
val result = vaultRepository.createSend(sendView = mockSendView, fileUri = null)
assertEquals(CreateSendResult.Error, result)
}
@Suppress("MaxLineLength")
@Test
fun `createSend with sendsService createSend success should return CreateSendResult success`() =
fun `createSend with TEXT and sendsService createSend success should return CreateSendResult success`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val mockSendView = createMockSendView(number = 1)
val mockSdkSend = createMockSdkSend(number = 1)
val mockSend = createMockSend(number = 1)
val mockSendView = createMockSendView(number = 1, type = SendType.TEXT)
val mockSdkSend = createMockSdkSend(number = 1, type = SendType.TEXT)
val mockSend = createMockSend(number = 1, type = SendTypeJson.TEXT)
val mockSendViewResult = createMockSendView(number = 2)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns mockSdkSend.asSuccess()
coEvery {
sendsService.createSend(body = createMockSendJsonRequest(number = 1))
sendsService.createSend(
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT)
.copy(fileLength = null),
)
} returns mockSend.asSuccess()
coEvery { vaultDiskSource.saveSend(userId, mockSend) } just runs
coEvery {
vaultSdkSource.decryptSend(userId, mockSdkSend)
} returns mockSendViewResult.asSuccess()
val result = vaultRepository.createSend(sendView = mockSendView)
val result = vaultRepository.createSend(sendView = mockSendView, fileUri = null)
assertEquals(CreateSendResult.Success(mockSendViewResult), result)
}
@Test
@Suppress("MaxLineLength")
fun `createSend with FILE and sendsService createFileSend failure should return CreateSendResult failure`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val uri = setupMockUri(url = "www.test.com")
val mockSendView = createMockSendView(number = 1)
val mockSdkSend = createMockSdkSend(number = 1)
val byteArray = byteArrayOf(1)
val encryptedByteArray = byteArrayOf(2)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns mockSdkSend.asSuccess()
every { fileManager.uriToByteArray(any()) } returns byteArray
coEvery {
vaultSdkSource.encryptBuffer(
userId = userId,
send = mockSdkSend,
fileBuffer = byteArray,
)
} returns encryptedByteArray.asSuccess()
coEvery {
sendsService.createFileSend(body = createMockSendJsonRequest(number = 1))
} returns IllegalStateException().asFailure()
val result = vaultRepository.createSend(sendView = mockSendView, fileUri = uri)
assertEquals(CreateSendResult.Error, result)
}
@Test
@Suppress("MaxLineLength")
fun `createSend with FILE and sendsService uploadFile failure should return CreateSendResult failure`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val url = "www.test.com"
val uri = setupMockUri(url = url)
val mockSendView = createMockSendView(number = 1)
val mockSdkSend = createMockSdkSend(number = 1)
val byteArray = byteArrayOf(1)
val encryptedByteArray = byteArrayOf(2)
val sendFileResponse = SendFileResponseJson(
url = url,
fileUploadType = SendFileResponseJson.FileUploadType.AZURE,
sendResponse = createMockSend(number = 1, type = SendTypeJson.FILE),
)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns mockSdkSend.asSuccess()
every { fileManager.uriToByteArray(any()) } returns byteArray
coEvery {
vaultSdkSource.encryptBuffer(
userId = userId,
send = mockSdkSend,
fileBuffer = byteArray,
)
} returns encryptedByteArray.asSuccess()
coEvery {
sendsService.createFileSend(body = createMockSendJsonRequest(number = 1))
} returns sendFileResponse.asSuccess()
coEvery {
sendsService.uploadFile(
sendFileResponse = sendFileResponse,
encryptedFile = encryptedByteArray,
)
} returns Throwable("Fail").asFailure()
val result = vaultRepository.createSend(sendView = mockSendView, fileUri = uri)
assertEquals(CreateSendResult.Error, result)
}
@Test
@Suppress("MaxLineLength")
fun `createSend with FILE and sendsService uploadFile success should return CreateSendResult success`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val url = "www.test.com"
val uri = setupMockUri(url = url)
val mockSendView = createMockSendView(number = 1)
val mockSdkSend = createMockSdkSend(number = 1)
val byteArray = byteArrayOf(1)
val encryptedByteArray = byteArrayOf(2)
val sendResponse = createMockSend(number = 1)
val sendFileResponse = SendFileResponseJson(
url = url,
fileUploadType = SendFileResponseJson.FileUploadType.AZURE,
sendResponse = sendResponse,
)
val mockSendViewResult = createMockSendView(number = 1)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns mockSdkSend.asSuccess()
every { fileManager.uriToByteArray(any()) } returns byteArray
coEvery {
vaultSdkSource.encryptBuffer(
userId = userId,
send = mockSdkSend,
fileBuffer = byteArray,
)
} returns encryptedByteArray.asSuccess()
coEvery {
sendsService.createFileSend(body = createMockSendJsonRequest(number = 1))
} returns sendFileResponse.asSuccess()
coEvery {
sendsService.uploadFile(
sendFileResponse = sendFileResponse,
encryptedFile = encryptedByteArray,
)
} returns sendResponse.asSuccess()
coEvery { vaultDiskSource.saveSend(userId, sendResponse) } just runs
coEvery {
vaultSdkSource.decryptSend(userId, mockSdkSend)
} returns mockSendViewResult.asSuccess()
val result = vaultRepository.createSend(sendView = mockSendView, fileUri = uri)
assertEquals(CreateSendResult.Success(mockSendViewResult), result)
}
@ -1636,14 +1787,15 @@ class VaultRepositoryTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "sendId1234"
val mockSendView = createMockSendView(number = 1)
val mockSendView = createMockSendView(number = 1, type = SendType.TEXT)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns createMockSdkSend(number = 1).asSuccess()
} returns createMockSdkSend(number = 1, type = SendType.TEXT).asSuccess()
coEvery {
sendsService.updateSend(
sendId = sendId,
body = createMockSendJsonRequest(number = 1),
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT)
.copy(fileLength = null),
)
} returns IllegalStateException().asFailure()
@ -1662,14 +1814,15 @@ class VaultRepositoryTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "sendId1234"
val mockSendView = createMockSendView(number = 1)
val mockSendView = createMockSendView(number = 1, type = SendType.TEXT)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns createMockSdkSend(number = 1).asSuccess()
} returns createMockSdkSend(number = 1, type = SendType.TEXT).asSuccess()
coEvery {
sendsService.updateSend(
sendId = sendId,
body = createMockSendJsonRequest(number = 1),
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT)
.copy(fileLength = null),
)
} returns UpdateSendResponseJson
.Invalid(
@ -1698,19 +1851,22 @@ class VaultRepositoryTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "sendId1234"
val mockSendView = createMockSendView(number = 1)
val mockSendView = createMockSendView(number = 1, type = SendType.TEXT)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns createMockSdkSend(number = 1).asSuccess()
val mockSend = createMockSend(number = 1)
} returns createMockSdkSend(number = 1, type = SendType.TEXT).asSuccess()
val mockSend = createMockSend(number = 1, type = SendTypeJson.TEXT)
coEvery {
sendsService.updateSend(
sendId = sendId,
body = createMockSendJsonRequest(number = 1),
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT)
.copy(fileLength = null),
)
} returns UpdateSendResponseJson.Success(send = mockSend).asSuccess()
coEvery {
vaultSdkSource.decryptSend(userId = userId, send = createMockSdkSend(number = 1))
vaultSdkSource.decryptSend(
userId = userId, send = createMockSdkSend(number = 1, type = SendType.TEXT),
)
} returns Throwable("Fail").asFailure()
coEvery { vaultDiskSource.saveSend(userId = userId, send = mockSend) } just runs
@ -1729,20 +1885,24 @@ class VaultRepositoryTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "sendId1234"
val mockSendView = createMockSendView(number = 1)
val mockSendView = createMockSendView(number = 1, type = SendType.TEXT)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns createMockSdkSend(number = 1).asSuccess()
val mockSend = createMockSend(number = 1)
} returns createMockSdkSend(number = 1, type = SendType.TEXT).asSuccess()
val mockSend = createMockSend(number = 1, type = SendTypeJson.TEXT)
coEvery {
sendsService.updateSend(
sendId = sendId,
body = createMockSendJsonRequest(number = 1),
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT)
.copy(fileLength = null),
)
} returns UpdateSendResponseJson.Success(send = mockSend).asSuccess()
val mockSendViewResult = createMockSendView(number = 2)
val mockSendViewResult = createMockSendView(number = 2, type = SendType.TEXT)
coEvery {
vaultSdkSource.decryptSend(userId = userId, send = createMockSdkSend(number = 1))
vaultSdkSource.decryptSend(
userId = userId,
send = createMockSdkSend(number = 1, type = SendType.TEXT),
)
} returns mockSendViewResult.asSuccess()
coEvery { vaultDiskSource.saveSend(userId = userId, send = mockSend) } just runs
@ -1992,6 +2152,19 @@ class VaultRepositoryTest {
}
}
private fun setupMockUri(
url: String,
queryParams: Map<String, String> = emptyMap(),
): Uri {
val mockUri = mockk<Uri> {
queryParams.forEach {
every { getQueryParameter(it.key) } returns it.value
}
}
every { Uri.parse(url) } returns mockUri
return mockUri
}
//endregion Helper functions
}

View file

@ -8,13 +8,22 @@ import org.junit.jupiter.api.Test
class VaultSdkSendExtensionsTest {
@Suppress("MaxLineLength")
@Test
fun `toEncryptedNetworkSend should convert a SDK-based Send to network-based Send`() {
fun `toEncryptedNetworkSend should convert a SDK-based Send to network-based Send with file length`() {
val sdkSend = createMockSdkSend(number = 1)
val networkSend = sdkSend.toEncryptedNetworkSend()
val networkSend = sdkSend.toEncryptedNetworkSend(fileLength = 1)
assertEquals(createMockSendJsonRequest(number = 1), networkSend)
}
@Suppress("MaxLineLength")
@Test
fun `toEncryptedNetworkSend should convert a SDK-based Send to network-based Send without file length`() {
val sdkSend = createMockSdkSend(number = 1)
val networkSend = sdkSend.toEncryptedNetworkSend(fileLength = null)
assertEquals(createMockSendJsonRequest(number = 1).copy(fileLength = null), networkSend)
}
@Test
fun `toEncryptedSdkSend should convert a network-based Send to SDK-based Send`() {
val syncSend = createMockSend(number = 1)

View file

@ -120,7 +120,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
every { toSendUrl(DEFAULT_ENVIRONMENT_URL) } returns sendUrl
}
coEvery {
vaultRepository.createSend(mockSendView)
vaultRepository.createSend(sendView = mockSendView, fileUri = null)
} returns CreateSendResult.Success(sendView = resultSendView)
val viewModel = createViewModel(initialState)
@ -131,7 +131,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
}
assertEquals(initialState, viewModel.stateFlow.value)
coVerify(exactly = 1) {
vaultRepository.createSend(mockSendView)
vaultRepository.createSend(sendView = mockSendView, fileUri = null)
}
}
@ -143,7 +143,9 @@ class AddSendViewModelTest : BaseViewModelTest() {
val initialState = DEFAULT_STATE.copy(viewState = viewState)
val mockSendView = mockk<SendView>()
every { viewState.toSendView(clock) } returns mockSendView
coEvery { vaultRepository.createSend(mockSendView) } returns CreateSendResult.Error
coEvery {
vaultRepository.createSend(sendView = mockSendView, fileUri = null)
} returns CreateSendResult.Error
val viewModel = createViewModel(initialState)
viewModel.stateFlow.test {
@ -168,7 +170,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
)
}
coVerify(exactly = 1) {
vaultRepository.createSend(mockSendView)
vaultRepository.createSend(sendView = mockSendView, fileUri = null)
}
}