From cb3dc71eab5ff08750878c0c96d7776fe3d7df73 Mon Sep 17 00:00:00 2001 From: David Perez Date: Wed, 24 Jan 2024 18:07:13 -0600 Subject: [PATCH] Add API support for deleting an attachment (#767) --- .../datasource/network/api/CiphersApi.kt | 9 +++ .../network/service/CiphersService.kt | 8 +++ .../network/service/CiphersServiceImpl.kt | 9 +++ .../data/vault/repository/VaultRepository.kt | 10 +++ .../vault/repository/VaultRepositoryImpl.kt | 35 +++++++++ .../model/DeleteAttachmentResult.kt | 17 +++++ .../network/service/CiphersServiceTest.kt | 12 ++++ .../vault/repository/VaultRepositoryTest.kt | 72 +++++++++++++++++++ 8 files changed, 172 insertions(+) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/DeleteAttachmentResult.kt 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 cec5625922..312851819e 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 @@ -54,6 +54,15 @@ interface CiphersApi { @Path("cipherId") cipherId: String, ): Result + /** + * Deletes an attachment from a cipher. + */ + @DELETE("ciphers/{cipherId}/attachment/{attachmentId}") + suspend fun deleteCipherAttachment( + @Path("cipherId") cipherId: String, + @Path("attachmentId") attachmentId: String, + ): Result + /** * Restores a 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 6eb3232735..4482d7167e 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 @@ -40,6 +40,14 @@ interface CiphersService { */ suspend fun softDeleteCipher(cipherId: String): Result + /** + * Attempt to delete an attachment from a cipher. + */ + suspend fun deleteCipherAttachment( + cipherId: String, + attachmentId: String, + ): Result + /** * Attempt to restore 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 1b50b85770..e2133ebecb 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 @@ -51,6 +51,15 @@ class CiphersServiceImpl constructor( override suspend fun softDeleteCipher(cipherId: String): Result = ciphersApi.softDeleteCipher(cipherId = cipherId) + override suspend fun deleteCipherAttachment( + cipherId: String, + attachmentId: String, + ): Result = + ciphersApi.deleteCipherAttachment( + cipherId = cipherId, + attachmentId = attachmentId, + ) + override suspend fun restoreCipher(cipherId: String): Result = ciphersApi.restoreCipher(cipherId = cipherId) } 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 32f8c02f0f..d2b3624a60 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 @@ -13,6 +13,7 @@ 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.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult +import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult @@ -241,4 +242,13 @@ interface VaultRepository : VaultLockManager { * Attempt to delete a send. */ suspend fun deleteSend(sendId: String): DeleteSendResult + + /** + * Attempt to delete an attachment from a send. + */ + suspend fun deleteCipherAttachment( + cipherId: String, + attachmentId: String, + cipherView: CipherView, + ): DeleteAttachmentResult } 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 754ddba839..5aee1fc9e8 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 @@ -40,6 +40,7 @@ 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.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult +import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult @@ -487,6 +488,40 @@ class VaultRepositoryImpl( ) } + override suspend fun deleteCipherAttachment( + cipherId: String, + attachmentId: String, + cipherView: CipherView, + ): DeleteAttachmentResult { + val userId = requireNotNull(activeUserId) + return ciphersService + .deleteCipherAttachment( + cipherId = cipherId, + attachmentId = attachmentId, + ) + .flatMap { + vaultSdkSource + .encryptCipher( + userId = userId, + cipherView = cipherView.copy( + attachments = cipherView.attachments?.mapNotNull { + if (it.id == attachmentId) null else it + }, + ), + ) + } + .onSuccess { cipher -> + vaultDiskSource.saveCipher( + userId = userId, + cipher = cipher.toEncryptedNetworkCipherResponse(), + ) + } + .fold( + onSuccess = { DeleteAttachmentResult.Success }, + onFailure = { DeleteAttachmentResult.Error }, + ) + } + override suspend fun restoreCipher( cipherId: String, cipherView: CipherView, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/DeleteAttachmentResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/DeleteAttachmentResult.kt new file mode 100644 index 0000000000..27d5eb0aa0 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/DeleteAttachmentResult.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.data.vault.repository.model + +/** + * Models result of deleting an attachment from a cipher. + */ +sealed class DeleteAttachmentResult { + + /** + * Attachment deleted successfully. + */ + data object Success : DeleteAttachmentResult() + + /** + * Generic error while deleting an attachment. + */ + data object Error : DeleteAttachmentResult() +} 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 4af336bb39..883107da13 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 @@ -81,6 +81,18 @@ class CiphersServiceTest : BaseServiceTest() { assertEquals(Unit, result.getOrThrow()) } + @Test + fun `deleteCipherAttachment should execute the deleteCipherAttachment API`() = runTest { + server.enqueue(MockResponse().setResponseCode(200)) + val cipherId = "cipherId" + val attachmentId = "attachmentId" + val result = ciphersService.deleteCipherAttachment( + cipherId = cipherId, + attachmentId = attachmentId, + ) + assertEquals(Unit, result.getOrThrow()) + } + @Test fun `shareCipher should execute the share cipher API`() = runTest { server.enqueue( 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 cfd3bfcea5..b1d60dc8a3 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 @@ -61,6 +61,7 @@ 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.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult +import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult @@ -1703,6 +1704,77 @@ class VaultRepositoryTest { unmockkStatic(Cipher::toEncryptedNetworkCipherResponse) } + @Suppress("MaxLineLength") + @Test + fun `deleteCipherAttachment with ciphersService deleteCipherAttachment failure should return DeleteAttachmentResult Error`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val cipherId = "mockId-1" + val attachmentId = "mockId-1" + coEvery { + ciphersService.deleteCipherAttachment( + cipherId = cipherId, + attachmentId = attachmentId, + ) + } returns Throwable("Fail").asFailure() + + val result = vaultRepository.deleteCipherAttachment( + cipherId = cipherId, + attachmentId = attachmentId, + cipherView = createMockCipherView(number = 1), + ) + + assertEquals(DeleteAttachmentResult.Error, result) + } + + @Suppress("MaxLineLength") + @Test + fun `deleteCipherAttachment with ciphersService deleteCipherAttachment success should return DeleteAttachmentResult success`() = + runTest { + mockkStatic(Cipher::toEncryptedNetworkCipherResponse) + every { + createMockSdkCipher(number = 1).toEncryptedNetworkCipherResponse() + } returns createMockCipher(number = 1) + val fixedInstant = Instant.parse("2021-01-01T00:00:00Z") + val userId = "mockId-1" + val cipherId = "mockId-1" + val attachmentId = "mockId-1" + coEvery { + vaultSdkSource.encryptCipher( + userId = userId, + cipherView = createMockCipherView(number = 1).copy( + attachments = emptyList(), + ), + ) + } returns createMockSdkCipher(number = 1).asSuccess() + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { + ciphersService.deleteCipherAttachment( + cipherId = cipherId, + attachmentId = attachmentId, + ) + } returns Unit.asSuccess() + coEvery { + vaultDiskSource.saveCipher( + userId = userId, + cipher = createMockCipher(number = 1), + ) + } returns Unit + val cipherView = createMockCipherView(number = 1) + mockkStatic(Instant::class) + every { Instant.now() } returns fixedInstant + + val result = vaultRepository.deleteCipherAttachment( + cipherId = cipherId, + attachmentId = attachmentId, + cipherView = cipherView, + ) + + assertEquals(DeleteAttachmentResult.Success, result) + unmockkStatic(Instant::class) + unmockkStatic(Cipher::toEncryptedNetworkCipherResponse) + } + @Suppress("MaxLineLength") @Test fun `restoreCipher with ciphersService restoreCipher failure should return RestoreCipherResult Error`() =