From a9b1623f8b134d4ea9f2efab04a1575e0bf549aa Mon Sep 17 00:00:00 2001 From: David Perez Date: Wed, 14 Jan 2026 08:49:01 -0600 Subject: [PATCH] PM-30774: Add archiving and unarchiving network requests (#6356) --- .../data/vault/manager/CipherManager.kt | 18 ++ .../data/vault/manager/CipherManagerImpl.kt | 72 +++++ .../repository/model/ArchiveCipherResult.kt | 17 ++ .../repository/model/UnarchiveCipherResult.kt | 17 ++ .../data/vault/manager/CipherManagerTest.kt | 285 ++++++++++++++++++ .../com/bitwarden/network/api/CiphersApi.kt | 16 + .../network/service/CiphersService.kt | 10 + .../network/service/CiphersServiceImpl.kt | 12 + .../network/service/CiphersServiceTest.kt | 17 +- 9 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/ArchiveCipherResult.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/UnarchiveCipherResult.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManager.kt index caf5997256..2a4f1d2445 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManager.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManager.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.manager import android.net.Uri import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult 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.DeleteAttachmentResult @@ -10,6 +11,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult /** @@ -57,6 +59,22 @@ interface CipherManager { */ suspend fun getCipher(cipherId: String): GetCipherResult + /** + * Attempt to archive a cipher. + */ + suspend fun archiveCipher( + cipherId: String, + cipherView: CipherView, + ): ArchiveCipherResult + + /** + * Attempt to unarchive a cipher. + */ + suspend fun unarchiveCipher( + cipherId: String, + cipherView: CipherView, + ): UnarchiveCipherResult + /** * Attempt to delete a cipher. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManagerImpl.kt index 30ab82a43c..36e4991c1d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManagerImpl.kt @@ -28,6 +28,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult 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.DeleteAttachmentResult @@ -35,6 +36,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse @@ -161,6 +163,76 @@ class CipherManagerImpl( ) } + override suspend fun archiveCipher( + cipherId: String, + cipherView: CipherView, + ): ArchiveCipherResult { + val userId = activeUserId ?: return ArchiveCipherResult.Error(NoActiveUserException()) + return cipherView + .encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId) + .flatMap { encryptionContext -> + ciphersService + .archiveCipher(cipherId = cipherId) + .flatMap { + vaultSdkSource.decryptCipher( + userId = userId, + cipher = encryptionContext.cipher, + ) + } + } + .flatMap { + vaultSdkSource.encryptCipher( + userId = userId, + cipherView = it.copy(archivedDate = clock.instant()), + ) + } + .onSuccess { + vaultDiskSource.saveCipher( + userId = userId, + cipher = it.toEncryptedNetworkCipherResponse(), + ) + } + .fold( + onSuccess = { ArchiveCipherResult.Success }, + onFailure = { ArchiveCipherResult.Error(error = it) }, + ) + } + + override suspend fun unarchiveCipher( + cipherId: String, + cipherView: CipherView, + ): UnarchiveCipherResult { + val userId = activeUserId ?: return UnarchiveCipherResult.Error(NoActiveUserException()) + return cipherView + .encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId) + .flatMap { encryptionContext -> + ciphersService + .unarchiveCipher(cipherId = cipherId) + .flatMap { + vaultSdkSource.decryptCipher( + userId = userId, + cipher = encryptionContext.cipher, + ) + } + } + .flatMap { + vaultSdkSource.encryptCipher( + userId = userId, + cipherView = it.copy(archivedDate = null), + ) + } + .onSuccess { + vaultDiskSource.saveCipher( + userId = userId, + cipher = it.toEncryptedNetworkCipherResponse(), + ) + } + .fold( + onSuccess = { UnarchiveCipherResult.Success }, + onFailure = { UnarchiveCipherResult.Error(error = it) }, + ) + } + override suspend fun hardDeleteCipher(cipherId: String): DeleteCipherResult { val userId = activeUserId ?: return DeleteCipherResult.Error(error = NoActiveUserException()) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/ArchiveCipherResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/ArchiveCipherResult.kt new file mode 100644 index 0000000000..93d4dd0065 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/ArchiveCipherResult.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.data.vault.repository.model + +/** + * Models result of archiving a cipher. + */ +sealed class ArchiveCipherResult { + + /** + * Cipher archived successfully. + */ + data object Success : ArchiveCipherResult() + + /** + * Generic error while archiving a cipher. + */ + data class Error(val error: Throwable) : ArchiveCipherResult() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/UnarchiveCipherResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/UnarchiveCipherResult.kt new file mode 100644 index 0000000000..265e6574c2 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/UnarchiveCipherResult.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.data.vault.repository.model + +/** + * Models result of unarchiving a cipher. + */ +sealed class UnarchiveCipherResult { + + /** + * Cipher unarchived successfully. + */ + data object Success : UnarchiveCipherResult() + + /** + * Generic error while unarchiving a cipher. + */ + data class Error(val error: Throwable) : UnarchiveCipherResult() +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManagerTest.kt index b43801e608..274768793e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CipherManagerTest.kt @@ -45,6 +45,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockEncryptionC import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkAttachment import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkCipher import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult 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.DeleteAttachmentResult @@ -52,6 +53,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse @@ -693,6 +695,289 @@ class CipherManagerTest { } } + @Test + fun `archiveCipher with no active user should return ArchiveCipherResult Error`() = runTest { + fakeAuthDiskSource.userState = null + + val result = cipherManager.archiveCipher( + cipherId = "cipherId", + cipherView = mockk(), + ) + + assertEquals(ArchiveCipherResult.Error(error = NoActiveUserException()), result) + } + + @Suppress("MaxLineLength") + @Test + fun `archiveCipher with ciphersService archiveCipher failure should return ArchiveCipherResult Error`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = MOCK_USER_STATE.activeUserId + val cipherId = "mockId-1" + val cipherView = createMockCipherView(number = 1) + val encryptionContext = createMockEncryptionContext(number = 1) + val error = Throwable("Fail") + coEvery { + vaultSdkSource.encryptCipher(userId = userId, cipherView = cipherView) + } returns encryptionContext.asSuccess() + coEvery { + ciphersService.archiveCipher(cipherId = cipherId) + } returns error.asFailure() + + val result = cipherManager.archiveCipher( + cipherId = cipherId, + cipherView = cipherView, + ) + + assertEquals(ArchiveCipherResult.Error(error = error), result) + } + + @Suppress("MaxLineLength") + @Test + fun `archiveCipher with ciphersService archiveCipher success should return ArchiveCipherResult success`() = + runTest { + val fixedInstant = Instant.parse("2023-10-27T12:00:00Z") + val userId = "mockId-1" + val cipherId = "mockId-1" + val encryptionContext = createMockEncryptionContext( + number = 1, + cipher = createMockSdkCipher(number = 1, clock = clock), + ) + val cipherView = createMockCipherView(number = 1) + coEvery { + vaultSdkSource.encryptCipher(userId = userId, cipherView = cipherView) + } returns encryptionContext.asSuccess() + coEvery { + vaultSdkSource.encryptCipher( + userId = userId, + cipherView = cipherView.copy(archivedDate = fixedInstant), + ) + } returns encryptionContext.asSuccess() + coEvery { + vaultSdkSource.decryptCipher(userId = userId, cipher = encryptionContext.cipher) + } returns cipherView.asSuccess() + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { ciphersService.archiveCipher(cipherId = cipherId) } returns Unit.asSuccess() + coEvery { + vaultDiskSource.saveCipher( + userId = userId, + cipher = encryptionContext.toEncryptedNetworkCipherResponse(), + ) + } just runs + + val result = cipherManager.archiveCipher( + cipherId = cipherId, + cipherView = cipherView, + ) + + assertEquals(ArchiveCipherResult.Success, result) + } + + @Suppress("MaxLineLength") + @Test + fun `archiveCipher with cipher migration success should return ArchiveCipherResult success`() = + runTest { + val fixedInstant = Instant.parse("2023-10-27T12:00:00Z") + val userId = "mockId-1" + val cipherId = "mockId-1" + val encryptionContext = createMockEncryptionContext( + number = 1, + cipher = createMockSdkCipher(number = 1, clock = clock), + ) + val cipherView = createMockCipherView(number = 1).copy(key = null) + val networkCipher = createMockCipher(number = 1).copy(key = null) + coEvery { + vaultSdkSource.encryptCipher(userId = userId, cipherView = cipherView) + } returns encryptionContext.asSuccess() + coEvery { + ciphersService.updateCipher( + cipherId = cipherId, + body = encryptionContext.toEncryptedNetworkCipher(), + ) + } returns UpdateCipherResponseJson.Success(networkCipher).asSuccess() + coEvery { + vaultDiskSource.saveCipher(userId = userId, cipher = networkCipher) + } just runs + coEvery { + vaultSdkSource.decryptCipher( + userId = userId, + cipher = networkCipher.toEncryptedSdkCipher(), + ) + } returns cipherView.asSuccess() + coEvery { + vaultSdkSource.encryptCipher( + userId = userId, + cipherView = cipherView.copy(archivedDate = fixedInstant), + ) + } returns encryptionContext.asSuccess() + coEvery { + vaultSdkSource.decryptCipher(userId = userId, cipher = encryptionContext.cipher) + } returns cipherView.asSuccess() + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { ciphersService.archiveCipher(cipherId = cipherId) } returns Unit.asSuccess() + coEvery { + vaultDiskSource.saveCipher( + userId = userId, + cipher = encryptionContext.toEncryptedNetworkCipherResponse(), + ) + } just runs + + val result = cipherManager.archiveCipher( + cipherId = cipherId, + cipherView = cipherView, + ) + + assertEquals(ArchiveCipherResult.Success, result) + coVerify(exactly = 1) { + ciphersService.updateCipher( + cipherId = cipherId, + body = encryptionContext.toEncryptedNetworkCipher(), + ) + vaultDiskSource.saveCipher(userId = userId, cipher = networkCipher) + } + } + + @Test + fun `unarchiveCipher with no active user should return UnarchiveCipherResult Error`() = + runTest { + fakeAuthDiskSource.userState = null + + val result = cipherManager.unarchiveCipher( + cipherId = "cipherId", + cipherView = mockk(), + ) + + assertEquals(UnarchiveCipherResult.Error(error = NoActiveUserException()), result) + } + + @Suppress("MaxLineLength") + @Test + fun `unarchiveCipher with ciphersService unarchiveCipher failure should return UnarchiveCipherResult Error`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = MOCK_USER_STATE.activeUserId + val cipherId = "mockId-1" + val cipherView = createMockCipherView(number = 1) + val encryptionContext = createMockEncryptionContext(number = 1) + val error = Throwable("Fail") + coEvery { + vaultSdkSource.encryptCipher(userId = userId, cipherView = cipherView) + } returns encryptionContext.asSuccess() + coEvery { + ciphersService.unarchiveCipher(cipherId = cipherId) + } returns error.asFailure() + + val result = cipherManager.unarchiveCipher( + cipherId = cipherId, + cipherView = cipherView, + ) + + assertEquals(UnarchiveCipherResult.Error(error = error), result) + } + + @Suppress("MaxLineLength") + @Test + fun `unarchiveCipher with ciphersService unarchiveCipher success should return UnarchiveCipherResult success`() = + runTest { + val userId = "mockId-1" + val cipherId = "mockId-1" + val encryptionContext = createMockEncryptionContext( + number = 1, + cipher = createMockSdkCipher(number = 1, clock = clock), + ) + val cipherView = createMockCipherView(number = 1) + coEvery { + vaultSdkSource.encryptCipher(userId = userId, cipherView = cipherView) + } returns encryptionContext.asSuccess() + coEvery { + vaultSdkSource.encryptCipher( + userId = userId, + cipherView = cipherView.copy(archivedDate = null), + ) + } returns encryptionContext.asSuccess() + coEvery { + vaultSdkSource.decryptCipher(userId = userId, cipher = encryptionContext.cipher) + } returns cipherView.asSuccess() + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { ciphersService.unarchiveCipher(cipherId = cipherId) } returns Unit.asSuccess() + coEvery { + vaultDiskSource.saveCipher( + userId = userId, + cipher = encryptionContext.toEncryptedNetworkCipherResponse(), + ) + } just runs + + val result = cipherManager.unarchiveCipher( + cipherId = cipherId, + cipherView = cipherView, + ) + + assertEquals(UnarchiveCipherResult.Success, result) + } + + @Suppress("MaxLineLength") + @Test + fun `unarchiveCipher with cipher migration success should return UnarchiveCipherResult success`() = + runTest { + val userId = "mockId-1" + val cipherId = "mockId-1" + val encryptionContext = createMockEncryptionContext( + number = 1, + cipher = createMockSdkCipher(number = 1, clock = clock), + ) + val cipherView = createMockCipherView(number = 1).copy(key = null) + val networkCipher = createMockCipher(number = 1).copy(key = null) + coEvery { + vaultSdkSource.encryptCipher(userId = userId, cipherView = cipherView) + } returns encryptionContext.asSuccess() + coEvery { + ciphersService.updateCipher( + cipherId = cipherId, + body = encryptionContext.toEncryptedNetworkCipher(), + ) + } returns UpdateCipherResponseJson.Success(networkCipher).asSuccess() + coEvery { + vaultDiskSource.saveCipher(userId = userId, cipher = networkCipher) + } just runs + coEvery { + vaultSdkSource.decryptCipher( + userId = userId, + cipher = networkCipher.toEncryptedSdkCipher(), + ) + } returns cipherView.asSuccess() + coEvery { + vaultSdkSource.encryptCipher( + userId = userId, + cipherView = cipherView.copy(archivedDate = null), + ) + } returns encryptionContext.asSuccess() + coEvery { + vaultSdkSource.decryptCipher(userId = userId, cipher = encryptionContext.cipher) + } returns cipherView.asSuccess() + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { ciphersService.unarchiveCipher(cipherId = cipherId) } returns Unit.asSuccess() + coEvery { + vaultDiskSource.saveCipher( + userId = userId, + cipher = encryptionContext.toEncryptedNetworkCipherResponse(), + ) + } just runs + + val result = cipherManager.unarchiveCipher( + cipherId = cipherId, + cipherView = cipherView, + ) + + assertEquals(UnarchiveCipherResult.Success, result) + coVerify(exactly = 1) { + ciphersService.updateCipher( + cipherId = cipherId, + body = encryptionContext.toEncryptedNetworkCipher(), + ) + vaultDiskSource.saveCipher(userId = userId, cipher = networkCipher) + } + } + @Test fun `hardDeleteCipher with no active user should return DeleteCipherResult Error`() = runTest { fakeAuthDiskSource.userState = null diff --git a/network/src/main/kotlin/com/bitwarden/network/api/CiphersApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/CiphersApi.kt index 17f666b8b6..855958397e 100644 --- a/network/src/main/kotlin/com/bitwarden/network/api/CiphersApi.kt +++ b/network/src/main/kotlin/com/bitwarden/network/api/CiphersApi.kt @@ -26,6 +26,22 @@ import retrofit2.http.Query @Suppress("TooManyFunctions") internal interface CiphersApi { + /** + * Archive a cipher. + */ + @PUT("ciphers/{cipherId}/archive") + suspend fun archiveCipher( + @Path("cipherId") cipherId: String, + ): NetworkResult + + /** + * Unarchive a cipher. + */ + @PUT("ciphers/{cipherId}/unarchive") + suspend fun unarchiveCipher( + @Path("cipherId") cipherId: String, + ): NetworkResult + /** * Create a cipher. */ diff --git a/network/src/main/kotlin/com/bitwarden/network/service/CiphersService.kt b/network/src/main/kotlin/com/bitwarden/network/service/CiphersService.kt index 34f99de9e4..25f392cb11 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/CiphersService.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/CiphersService.kt @@ -21,6 +21,16 @@ import java.io.File */ @Suppress("TooManyFunctions") interface CiphersService { + /** + * Attempt to archive a cipher. + */ + suspend fun archiveCipher(cipherId: String): Result + + /** + * Attempt to unarchive a cipher. + */ + suspend fun unarchiveCipher(cipherId: String): Result + /** * Attempt to create a cipher. */ diff --git a/network/src/main/kotlin/com/bitwarden/network/service/CiphersServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/CiphersServiceImpl.kt index bba1e352df..01263ee837 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/CiphersServiceImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/CiphersServiceImpl.kt @@ -39,6 +39,18 @@ internal class CiphersServiceImpl( private val json: Json, private val clock: Clock, ) : CiphersService { + override suspend fun archiveCipher( + cipherId: String, + ): Result = ciphersApi + .archiveCipher(cipherId = cipherId) + .toResult() + + override suspend fun unarchiveCipher( + cipherId: String, + ): Result = ciphersApi + .unarchiveCipher(cipherId = cipherId) + .toResult() + override suspend fun createCipher( body: CipherJsonRequest, ): Result = diff --git a/network/src/test/kotlin/com/bitwarden/network/service/CiphersServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/CiphersServiceTest.kt index e3aa7130b0..373fc515fe 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/CiphersServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/CiphersServiceTest.kt @@ -27,7 +27,6 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic import kotlinx.coroutines.test.runTest -import kotlinx.serialization.encodeToString import okhttp3.mockwebserver.MockResponse import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -65,6 +64,22 @@ class CiphersServiceTest : BaseServiceTest() { unmockkStatic(Uri::class) } + @Test + fun `archiveCipher should execute the archiveCipher API`() = runTest { + server.enqueue(MockResponse().setResponseCode(200)) + val cipherId = "cipherId" + val result = ciphersService.archiveCipher(cipherId = cipherId) + assertEquals(Unit, result.getOrThrow()) + } + + @Test + fun `unarchiveCipher should execute the unarchiveCipher API`() = runTest { + server.enqueue(MockResponse().setResponseCode(200)) + val cipherId = "cipherId" + val result = ciphersService.unarchiveCipher(cipherId = cipherId) + assertEquals(Unit, result.getOrThrow()) + } + @Test fun `createCipher should return the correct response`() = runTest { server.enqueue(MockResponse().setBody(CREATE_RESTORE_UPDATE_CIPHER_SUCCESS_JSON))