diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt index 312851819..0d42ec874 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt @@ -1,8 +1,11 @@ package com.x8bit.bitwarden.data.vault.datasource.network.api +import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonResponse import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest 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 @@ -20,6 +23,25 @@ interface CiphersApi { @POST("ciphers") suspend fun createCipher(@Body body: CipherJsonRequest): Result + /** + * Associates an attachment with a cipher. + */ + @POST("ciphers/{cipherId}/attachment/v2") + suspend fun createAttachment( + @Path("cipherId") cipherId: String, + @Body body: AttachmentJsonRequest, + ): Result + + /** + * Uploads the attachment associated with a cipher. + */ + @POST("ciphers/{cipherId}/attachment/{attachmentId}") + suspend fun uploadAttachment( + @Path("cipherId") cipherId: String, + @Path("attachmentId") attachmentId: String, + @Body body: MultipartBody, + ): Result + /** * Updates a cipher. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt index 5c70f4eb1..8b4e3b789 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt @@ -28,9 +28,17 @@ object VaultNetworkModule { fun provideCiphersService( retrofits: Retrofits, json: Json, + clock: Clock, ): CiphersService = CiphersServiceImpl( + azureApi = retrofits + .staticRetrofitBuilder + // This URL will be overridden dynamically + .baseUrl("https://www.bitwarden.com") + .build() + .create(), ciphersApi = retrofits.authenticatedApiRetrofit.create(), json = json, + clock = clock, ) @Provides @@ -43,7 +51,7 @@ object VaultNetworkModule { azureApi = retrofits .staticRetrofitBuilder // This URL will be overridden dynamically - .baseUrl("https://www.bitwaredn.com") + .baseUrl("https://www.bitwarden.com") .build() .create(), sendsApi = retrofits.authenticatedApiRetrofit.create(), diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentJsonRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentJsonRequest.kt new file mode 100644 index 000000000..d5dfa5991 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentJsonRequest.kt @@ -0,0 +1,19 @@ +package com.x8bit.bitwarden.data.vault.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents a request to create an attachment. + */ +@Serializable +data class AttachmentJsonRequest( + @SerialName("fileName") + val fileName: String, + + @SerialName("key") + val key: String, + + @SerialName("fileSize") + val fileSize: String, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentJsonResponse.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentJsonResponse.kt new file mode 100644 index 000000000..85370071d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentJsonResponse.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.data.vault.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents the JSON response from creating a new attachment. + */ +@Serializable +data class AttachmentJsonResponse( + @SerialName("attachmentId") + val attachmentId: String, + + @SerialName("url") + val url: String, + + @SerialName("fileUploadType") + val fileUploadType: FileUploadType, + + @SerialName("cipherResponse") + val cipherResponse: SyncResponseJson.Cipher, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt index 4482d7167..ec766333c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt @@ -1,5 +1,7 @@ package com.x8bit.bitwarden.data.vault.datasource.network.service +import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonResponse import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson @@ -14,6 +16,22 @@ interface CiphersService { */ suspend fun createCipher(body: CipherJsonRequest): Result + /** + * Attempt to upload an attachment file. + */ + suspend fun uploadAttachment( + attachmentJsonResponse: AttachmentJsonResponse, + encryptedFile: ByteArray, + ): Result + + /** + * Attempt to create an attachment. + */ + suspend fun createAttachment( + cipherId: String, + body: AttachmentJsonRequest, + ): Result + /** * Attempt to update a cipher. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt index e2133ebec..431667c97 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt @@ -1,21 +1,88 @@ 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.CiphersApi +import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonResponse import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.FileUploadType import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson 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 -class CiphersServiceImpl constructor( +class CiphersServiceImpl( + private val azureApi: AzureApi, private val ciphersApi: CiphersApi, private val json: Json, + private val clock: Clock, ) : CiphersService { override suspend fun createCipher(body: CipherJsonRequest): Result = ciphersApi.createCipher(body = body) + override suspend fun createAttachment( + cipherId: String, + body: AttachmentJsonRequest, + ): Result = + ciphersApi.createAttachment( + cipherId = cipherId, + body = body, + ) + + override suspend fun uploadAttachment( + attachmentJsonResponse: AttachmentJsonResponse, + encryptedFile: ByteArray, + ): Result { + val cipher = attachmentJsonResponse.cipherResponse + return when (attachmentJsonResponse.fileUploadType) { + FileUploadType.DIRECT -> { + ciphersApi.uploadAttachment( + cipherId = requireNotNull(cipher.id), + attachmentId = attachmentJsonResponse.attachmentId, + body = MultipartBody + .Builder( + boundary = "--BWMobileFormBoundary${clock.instant().toEpochMilli()}", + ) + .addPart( + part = MultipartBody.Part.createFormData( + body = encryptedFile.toRequestBody( + contentType = "application/octet-stream".toMediaType(), + ), + name = "data", + filename = cipher + .attachments + ?.find { it.id == attachmentJsonResponse.attachmentId } + ?.fileName, + ), + ) + .build(), + ) + } + + FileUploadType.AZURE -> { + azureApi.uploadAzureBlob( + url = attachmentJsonResponse.url, + date = DateTimeFormatter + .RFC_1123_DATE_TIME + .format(ZonedDateTime.ofInstant(clock.instant(), ZoneOffset.UTC)), + version = attachmentJsonResponse.url.toUri().getQueryParameter("sv"), + body = encryptedFile.toRequestBody(), + ) + } + } + .map { cipher } + } + override suspend fun updateCipher( cipherId: String, body: CipherJsonRequest, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index 0e7a8d773..d778eb505 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -11,6 +11,7 @@ import com.bitwarden.crypto.Kdf import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.vault.manager.VaultLockManager import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem +import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult @@ -182,6 +183,17 @@ interface VaultRepository : VaultLockManager { */ suspend fun createCipher(cipherView: CipherView): CreateCipherResult + /** + * Attempt to create an attachment for the given [cipherView]. + */ + suspend fun createAttachment( + cipherId: String, + cipherView: CipherView, + fileSizeBytes: String, + fileName: String, + fileUri: Uri, + ): CreateAttachmentResult + /** * Attempt to delete a cipher. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index f52207999..fe9653529 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.vault.repository import android.net.Uri +import com.bitwarden.core.AttachmentView import com.bitwarden.core.CipherType import com.bitwarden.core.CipherView import com.bitwarden.core.CollectionView @@ -26,6 +27,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.updateToPendingOrLoadin 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.AttachmentJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson @@ -38,6 +40,7 @@ import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManager import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem +import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult @@ -56,6 +59,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkSend +import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollectionList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList @@ -643,6 +647,71 @@ class VaultRepositoryImpl( ) } + override suspend fun createAttachment( + cipherId: String, + cipherView: CipherView, + fileSizeBytes: String, + fileName: String, + fileUri: Uri, + ): CreateAttachmentResult { + val userId = requireNotNull(activeUserId) + val attachmentView = AttachmentView( + id = null, + url = null, + size = fileSizeBytes, + sizeName = null, + fileName = fileName, + key = null, + ) + return vaultSdkSource + .encryptCipher( + userId = userId, + cipherView = cipherView, + ) + .flatMap { cipher -> + vaultSdkSource.encryptAttachment( + userId = userId, + cipher = cipher, + attachmentView = attachmentView, + fileBuffer = fileManager.uriToByteArray(fileUri = fileUri), + ) + } + .flatMap { attachmentEncryptResult -> + ciphersService + .createAttachment( + cipherId = cipherId, + body = AttachmentJsonRequest( + // We know these values are present because + // - the filename/size are passed into the function + // - the SDK call fills in the key + fileName = requireNotNull(attachmentEncryptResult.attachment.fileName), + key = requireNotNull(attachmentEncryptResult.attachment.key), + fileSize = requireNotNull(attachmentEncryptResult.attachment.size), + ), + ) + .flatMap { attachmentJsonResponse -> + ciphersService.uploadAttachment( + attachmentJsonResponse = attachmentJsonResponse, + encryptedFile = attachmentEncryptResult.contents, + ) + } + } + .onSuccess { + // Save the send immediately, regardless of whether the decrypt succeeds + vaultDiskSource.saveCipher(userId = userId, cipher = it) + } + .flatMap { + vaultSdkSource.decryptCipher( + userId = userId, + cipher = it.toEncryptedSdkCipher(), + ) + } + .fold( + onFailure = { CreateAttachmentResult.Error }, + onSuccess = { CreateAttachmentResult.Success(it) }, + ) + } + override suspend fun createSend( sendView: SendView, fileUri: Uri?, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/CreateAttachmentResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/CreateAttachmentResult.kt new file mode 100644 index 000000000..91b5ac48b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/CreateAttachmentResult.kt @@ -0,0 +1,21 @@ +package com.x8bit.bitwarden.data.vault.repository.model + +import com.bitwarden.core.CipherView + +/** + * Models result of creating an attachment. + */ +sealed class CreateAttachmentResult { + + /** + * Attachment created successfully. + */ + data class Success( + val cipherView: CipherView, + ) : CreateAttachmentResult() + + /** + * Generic error while creating an attachment. + */ + data object Error : CreateAttachmentResult() +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentEncryptResultUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentEncryptResultUtil.kt new file mode 100644 index 000000000..f6db78f19 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentEncryptResultUtil.kt @@ -0,0 +1,13 @@ +package com.x8bit.bitwarden.data.vault.datasource.network.model + +import com.bitwarden.core.AttachmentEncryptResult +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkAttachment + +/** + * Create a mock [AttachmentEncryptResult] with a given [number]. + */ +fun createMockAttachmentEncryptResult(number: Int): AttachmentEncryptResult = + AttachmentEncryptResult( + attachment = createMockSdkAttachment(number = 1), + contents = byteArrayOf(number.toByte()), + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentJsonRequestUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentJsonRequestUtil.kt new file mode 100644 index 000000000..b47a721d3 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentJsonRequestUtil.kt @@ -0,0 +1,11 @@ +package com.x8bit.bitwarden.data.vault.datasource.network.model + +/** + * Create a mock [CipherJsonRequest] with a given [number]. + */ +fun createMockAttachmentJsonRequest(number: Int): AttachmentJsonRequest = + AttachmentJsonRequest( + fileName = "mockFileName-$number", + key = "mockKey-$number", + fileSize = "1000", + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentJsonResponseUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentJsonResponseUtil.kt new file mode 100644 index 000000000..f154b90f1 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/AttachmentJsonResponseUtil.kt @@ -0,0 +1,15 @@ +package com.x8bit.bitwarden.data.vault.datasource.network.model + +/** + * Create a mock [AttachmentJsonResponse] with a given [number]. + */ +fun createMockAttachmentJsonResponse( + number: Int, + fileUploadType: FileUploadType = FileUploadType.AZURE, +): AttachmentJsonResponse = + AttachmentJsonResponse( + attachmentId = "mockAttachmentId-$number", + url = "mockUrl-$number", + fileUploadType = fileUploadType, + cipherResponse = createMockCipher(number = number), + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt index 883107da1..c573b8f82 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt @@ -1,25 +1,56 @@ 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.CiphersApi +import com.x8bit.bitwarden.data.vault.datasource.network.model.FileUploadType import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentJsonResponse import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipherJsonRequest +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 CiphersServiceTest : BaseServiceTest() { + private val clock: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) + private val azureApi: AzureApi = retrofit.create() private val ciphersApi: CiphersApi = retrofit.create() private val ciphersService: CiphersService = CiphersServiceImpl( + azureApi = azureApi, ciphersApi = ciphersApi, json = json, + clock = clock, ) + @BeforeEach + fun setup() { + mockkStatic(Uri::class) + } + + @AfterEach + fun tearDown() { + unmockkStatic(Uri::class) + } + @Test fun `createCipher should return the correct response`() = runTest { server.enqueue(MockResponse().setBody(CREATE_UPDATE_CIPHER_SUCCESS_JSON)) @@ -32,6 +63,56 @@ class CiphersServiceTest : BaseServiceTest() { ) } + @Test + fun `createAttachment should return the correct response`() = runTest { + server.enqueue(MockResponse().setBody(CREATE_ATTACHMENT_SUCCESS_JSON)) + val result = ciphersService.createAttachment( + cipherId = "mockId-1", + body = createMockAttachmentJsonRequest(number = 1), + ) + assertEquals( + createMockAttachmentJsonResponse(number = 1), + result.getOrThrow(), + ) + } + + @Test + fun `uploadAttachment with Azure uploadFile success should return cipher`() = runTest { + setupMockUri(url = "mockUrl-1", queryParams = mapOf("sv" to "2024-04-03")) + val mockCipher = createMockCipher(number = 1) + val attachmentJsonResponse = createMockAttachmentJsonResponse( + number = 1, + fileUploadType = FileUploadType.AZURE, + ) + val encryptedFile = byteArrayOf() + server.enqueue(MockResponse().setResponseCode(201)) + + val result = ciphersService.uploadAttachment( + attachmentJsonResponse = attachmentJsonResponse, + encryptedFile = encryptedFile, + ) + + assertEquals(mockCipher, result.getOrThrow()) + } + + @Test + fun `uploadAttachment with Direct uploadFile success should return cipher`() = runTest { + val mockCipher = createMockCipher(number = 1) + val attachmentJsonResponse = createMockAttachmentJsonResponse( + number = 1, + fileUploadType = FileUploadType.DIRECT, + ) + val encryptedFile = byteArrayOf() + server.enqueue(MockResponse().setResponseCode(201)) + + val result = ciphersService.uploadAttachment( + attachmentJsonResponse = attachmentJsonResponse, + encryptedFile = encryptedFile, + ) + + assertEquals(mockCipher, result.getOrThrow()) + } + @Test fun `updateCipher with success response should return a Success with the correct cipher`() = runTest { @@ -123,6 +204,116 @@ class CiphersServiceTest : BaseServiceTest() { } } +private fun setupMockUri( + url: String, + queryParams: Map, +): Uri { + val mockUri = mockk { + queryParams.forEach { + every { getQueryParameter(it.key) } returns it.value + } + } + every { Uri.parse(url) } returns mockUri + return mockUri +} + +private const val CREATE_ATTACHMENT_SUCCESS_JSON = """ +{ + "attachmentId":"mockAttachmentId-1", + "url":"mockUrl-1", + "fileUploadType":1, + "cipherResponse":{ + "notes": "mockNotes-1", + "attachments": [ + { + "fileName": "mockFileName-1", + "size": 1, + "sizeName": "mockSizeName-1", + "id": "mockId-1", + "url": "mockUrl-1", + "key": "mockKey-1" + } + ], + "organizationUseTotp": false, + "reprompt": 0, + "edit": false, + "passwordHistory": [ + { + "password": "mockPassword-1", + "lastUsedDate": "2023-10-27T12:00:00.00Z" + } + ], + "revisionDate": "2023-10-27T12:00:00.00Z", + "type": 1, + "login": { + "uris": [ + { + "match": 1, + "uri": "mockUri-1" + } + ], + "totp": "mockTotp-1", + "password": "mockPassword-1", + "passwordRevisionDate": "2023-10-27T12:00:00.00Z", + "autofillOnPageLoad": false, + "uri": "mockUri-1", + "username": "mockUsername-1" + }, + "creationDate": "2023-10-27T12:00:00.00Z", + "secureNote": { + "type": 0 + }, + "folderId": "mockFolderId-1", + "organizationId": "mockOrganizationId-1", + "deletedDate": "2023-10-27T12:00:00.00Z", + "identity": { + "passportNumber": "mockPassportNumber-1", + "lastName": "mockLastName-1", + "address3": "mockAddress3-1", + "address2": "mockAddress2-1", + "city": "mockCity-1", + "country": "mockCountry-1", + "address1": "mockAddress1-1", + "postalCode": "mockPostalCode-1", + "title": "mockTitle-1", + "ssn": "mockSsn-1", + "firstName": "mockFirstName-1", + "phone": "mockPhone-1", + "middleName": "mockMiddleName-1", + "company": "mockCompany-1", + "licenseNumber": "mockLicenseNumber-1", + "state": "mockState-1", + "email": "mockEmail-1", + "username": "mockUsername-1" + }, + "collectionIds": [ + "mockCollectionId-1" + ], + "name": "mockName-1", + "id": "mockId-1" + "fields": [ + { + "linkedId": 100, + "name": "mockName-1", + "type": 1, + "value": "mockValue-1" + } + ], + "viewPassword": false, + "favorite": false, + "card": { + "number": "mockNumber-1", + "expMonth": "mockExpMonth-1", + "code": "mockCode-1", + "expYear": "mockExpirationYear-1", + "cardholderName": "mockCardholderName-1", + "brand": "mockBrand-1" + }, + "key": "mockKey-1" + } +} +""" + private const val CREATE_UPDATE_CIPHER_SUCCESS_JSON = """ { "notes": "mockNotes-1", diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index c0696d1d0..521303090 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -27,6 +27,7 @@ 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.AttachmentJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.FileUploadType import com.x8bit.bitwarden.data.vault.datasource.network.model.SendFileResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SendTypeJson @@ -34,6 +35,8 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRe 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 +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentEncryptResult +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentJsonResponse import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCollection @@ -48,6 +51,7 @@ 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.datasource.sdk.model.InitializeCryptoResult +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockAttachmentView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView @@ -60,6 +64,7 @@ import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManager import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem +import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult @@ -76,6 +81,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.data.vault.repository.model.VaultState import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse +import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollectionList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList @@ -2367,6 +2373,332 @@ class VaultRepositoryTest { ) } + @Suppress("MaxLineLength") + @Test + fun `createAttachment with encryptCipher failure should return CreateAttachmentResult Error`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = "mockId-1" + val cipherId = "cipherId-1" + val mockUri = setupMockUri(url = "www.test.com") + val mockCipherView = createMockCipherView(number = 1) + val mockFileName = "mockFileName-1" + val mockFileSize = "1" + coEvery { + vaultSdkSource.encryptCipher(userId = userId, cipherView = mockCipherView) + } returns Throwable("Fail").asFailure() + + val result = vaultRepository.createAttachment( + cipherId = cipherId, + cipherView = mockCipherView, + fileSizeBytes = mockFileSize, + fileName = mockFileName, + fileUri = mockUri, + ) + + assertEquals(CreateAttachmentResult.Error, result) + } + + @Suppress("MaxLineLength") + @Test + fun `createAttachment with encryptAttachment failure should return CreateAttachmentResult Error`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = "mockId-1" + val cipherId = "cipherId-1" + val mockUri = setupMockUri(url = "www.test.com") + val mockCipherView = createMockCipherView(number = 1) + val mockCipher = createMockSdkCipher(number = 1) + val mockFileName = "mockFileName-1" + val mockFileSize = "1" + val mockAttachmentView = createMockAttachmentView(number = 1).copy( + sizeName = null, + id = null, + url = null, + key = null, + ) + val mockByteArray = byteArrayOf(1, 2) + coEvery { + vaultSdkSource.encryptCipher(userId = userId, cipherView = mockCipherView) + } returns mockCipher.asSuccess() + every { fileManager.uriToByteArray(fileUri = mockUri) } returns mockByteArray + coEvery { + vaultSdkSource.encryptAttachment( + userId = userId, + cipher = mockCipher, + attachmentView = mockAttachmentView, + fileBuffer = mockByteArray, + ) + } returns Throwable("Fail").asFailure() + + val result = vaultRepository.createAttachment( + cipherId = cipherId, + cipherView = mockCipherView, + fileSizeBytes = mockFileSize, + fileName = mockFileName, + fileUri = mockUri, + ) + + assertEquals(CreateAttachmentResult.Error, result) + } + + @Suppress("MaxLineLength") + @Test + fun `createAttachment with createAttachment failure should return CreateAttachmentResult Error`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = "mockId-1" + val cipherId = "cipherId-1" + val mockUri = setupMockUri(url = "www.test.com") + val mockCipherView = createMockCipherView(number = 1) + val mockCipher = createMockSdkCipher(number = 1) + val mockFileName = "mockFileName-1" + val mockFileSize = "1" + val mockAttachmentView = createMockAttachmentView(number = 1).copy( + sizeName = null, + id = null, + url = null, + key = null, + ) + val mockByteArray = byteArrayOf(1, 2) + val mockAttachmentEncryptResult = createMockAttachmentEncryptResult(number = 1) + coEvery { + vaultSdkSource.encryptCipher(userId = userId, cipherView = mockCipherView) + } returns mockCipher.asSuccess() + every { fileManager.uriToByteArray(fileUri = mockUri) } returns mockByteArray + coEvery { + vaultSdkSource.encryptAttachment( + userId = userId, + cipher = mockCipher, + attachmentView = mockAttachmentView, + fileBuffer = mockByteArray, + ) + } returns mockAttachmentEncryptResult.asSuccess() + coEvery { + ciphersService.createAttachment( + cipherId = cipherId, + body = AttachmentJsonRequest( + fileName = mockFileName, + key = "mockKey-1", + fileSize = mockFileSize, + ), + ) + } returns Throwable("Fail").asFailure() + + val result = vaultRepository.createAttachment( + cipherId = cipherId, + cipherView = mockCipherView, + fileSizeBytes = mockFileSize, + fileName = mockFileName, + fileUri = mockUri, + ) + + assertEquals(CreateAttachmentResult.Error, result) + } + + @Suppress("MaxLineLength") + @Test + fun `createAttachment with uploadAttachment failure should return CreateAttachmentResult Error`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = "mockId-1" + val cipherId = "cipherId-1" + val mockUri = setupMockUri(url = "www.test.com") + val mockCipherView = createMockCipherView(number = 1) + val mockCipher = createMockSdkCipher(number = 1) + val mockFileName = "mockFileName-1" + val mockFileSize = "1" + val mockAttachmentView = createMockAttachmentView(number = 1).copy( + sizeName = null, + id = null, + url = null, + key = null, + ) + val mockByteArray = byteArrayOf(1, 2) + val mockAttachmentEncryptResult = createMockAttachmentEncryptResult(number = 1) + val mockAttachmentJsonResponse = createMockAttachmentJsonResponse(number = 1) + coEvery { + vaultSdkSource.encryptCipher(userId = userId, cipherView = mockCipherView) + } returns mockCipher.asSuccess() + every { fileManager.uriToByteArray(fileUri = mockUri) } returns mockByteArray + coEvery { + vaultSdkSource.encryptAttachment( + userId = userId, + cipher = mockCipher, + attachmentView = mockAttachmentView, + fileBuffer = mockByteArray, + ) + } returns mockAttachmentEncryptResult.asSuccess() + coEvery { + ciphersService.createAttachment( + cipherId = cipherId, + body = AttachmentJsonRequest( + fileName = mockFileName, + key = "mockKey-1", + fileSize = mockFileSize, + ), + ) + } returns mockAttachmentJsonResponse.asSuccess() + coEvery { + ciphersService.uploadAttachment( + attachmentJsonResponse = mockAttachmentJsonResponse, + encryptedFile = mockAttachmentEncryptResult.contents, + ) + } returns Throwable("Fail").asFailure() + + val result = vaultRepository.createAttachment( + cipherId = cipherId, + cipherView = mockCipherView, + fileSizeBytes = mockFileSize, + fileName = mockFileName, + fileUri = mockUri, + ) + + assertEquals(CreateAttachmentResult.Error, result) + } + + @Suppress("MaxLineLength") + @Test + fun `createAttachment with decryptCipher failure should return CreateAttachmentResult Error`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = "mockId-1" + val cipherId = "cipherId-1" + val mockUri = setupMockUri(url = "www.test.com") + val mockCipherView = createMockCipherView(number = 1) + val mockCipher = createMockSdkCipher(number = 1) + val mockFileName = "mockFileName-1" + val mockFileSize = "1" + val mockAttachmentView = createMockAttachmentView(number = 1).copy( + sizeName = null, + id = null, + url = null, + key = null, + ) + val mockByteArray = byteArrayOf(1, 2) + val mockAttachmentEncryptResult = createMockAttachmentEncryptResult(number = 1) + val mockAttachmentJsonResponse = createMockAttachmentJsonResponse(number = 1) + val mockCipherResponse = createMockCipher(number = 1) + coEvery { + vaultSdkSource.encryptCipher(userId = userId, cipherView = mockCipherView) + } returns mockCipher.asSuccess() + every { fileManager.uriToByteArray(fileUri = mockUri) } returns mockByteArray + coEvery { + vaultSdkSource.encryptAttachment( + userId = userId, + cipher = mockCipher, + attachmentView = mockAttachmentView, + fileBuffer = mockByteArray, + ) + } returns mockAttachmentEncryptResult.asSuccess() + coEvery { + ciphersService.createAttachment( + cipherId = cipherId, + body = AttachmentJsonRequest( + fileName = mockFileName, + key = "mockKey-1", + fileSize = mockFileSize, + ), + ) + } returns mockAttachmentJsonResponse.asSuccess() + coEvery { + ciphersService.uploadAttachment( + attachmentJsonResponse = mockAttachmentJsonResponse, + encryptedFile = mockAttachmentEncryptResult.contents, + ) + } returns mockCipherResponse.asSuccess() + coEvery { + vaultDiskSource.saveCipher(userId = userId, cipher = mockCipherResponse) + } just runs + coEvery { + vaultSdkSource.decryptCipher( + userId = userId, + cipher = mockCipherResponse.toEncryptedSdkCipher(), + ) + } returns Throwable("Fail").asFailure() + + val result = vaultRepository.createAttachment( + cipherId = cipherId, + cipherView = mockCipherView, + fileSizeBytes = mockFileSize, + fileName = mockFileName, + fileUri = mockUri, + ) + + assertEquals(CreateAttachmentResult.Error, result) + } + + @Suppress("MaxLineLength") + @Test + fun `createAttachment with createAttachment success should return CreateAttachmentResult Success`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = "mockId-1" + val cipherId = "cipherId-1" + val mockUri = setupMockUri(url = "www.test.com") + val mockCipherView = createMockCipherView(number = 1) + val mockCipher = createMockSdkCipher(number = 1) + val mockFileName = "mockFileName-1" + val mockFileSize = "1" + val mockAttachmentView = createMockAttachmentView(number = 1).copy( + sizeName = null, + id = null, + url = null, + key = null, + ) + val mockByteArray = byteArrayOf(1, 2) + val mockAttachmentEncryptResult = createMockAttachmentEncryptResult(number = 1) + val mockAttachmentJsonResponse = createMockAttachmentJsonResponse(number = 1) + val mockCipherResponse = createMockCipher(number = 1) + coEvery { + vaultSdkSource.encryptCipher(userId = userId, cipherView = mockCipherView) + } returns mockCipher.asSuccess() + every { fileManager.uriToByteArray(fileUri = mockUri) } returns mockByteArray + coEvery { + vaultSdkSource.encryptAttachment( + userId = userId, + cipher = mockCipher, + attachmentView = mockAttachmentView, + fileBuffer = mockByteArray, + ) + } returns mockAttachmentEncryptResult.asSuccess() + coEvery { + ciphersService.createAttachment( + cipherId = cipherId, + body = AttachmentJsonRequest( + fileName = mockFileName, + key = "mockKey-1", + fileSize = mockFileSize, + ), + ) + } returns mockAttachmentJsonResponse.asSuccess() + coEvery { + ciphersService.uploadAttachment( + attachmentJsonResponse = mockAttachmentJsonResponse, + encryptedFile = mockAttachmentEncryptResult.contents, + ) + } returns mockCipherResponse.asSuccess() + coEvery { + vaultDiskSource.saveCipher(userId = userId, cipher = mockCipherResponse) + } just runs + coEvery { + vaultSdkSource.decryptCipher( + userId = userId, + cipher = mockCipherResponse.toEncryptedSdkCipher(), + ) + } returns mockCipherView.asSuccess() + + val result = vaultRepository.createAttachment( + cipherId = cipherId, + cipherView = mockCipherView, + fileSizeBytes = mockFileSize, + fileName = mockFileName, + fileUri = mockUri, + ) + + assertEquals(CreateAttachmentResult.Success(mockCipherView), result) + } + @Test fun `generateTotp should return a success result on getting a code`() = runTest { val totpResponse = TotpResponse("Testcode", 30u) @@ -2728,12 +3060,6 @@ class VaultRepositoryTest { return mockUri } - private fun setupMockInstant(): Instant { - val mockInstant = mockk() - every { Instant.now() } returns Instant.MIN - return mockInstant - } - //endregion Helper functions }