PM-30774: Add archiving and unarchiving network requests (#6356)

This commit is contained in:
David Perez authored and GitHub committed 2026-01-14 14:49:01 +00:00
1 parent 6d72d3a1c9
commit a9b1623f8b
9 files changed
+463 -1

No files matched your search

@@ -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.
*/
@@ -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())
@@ -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()
}
@@ -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()
}
@@ -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
@@ -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<Unit>
/**
* Unarchive a cipher.
*/
@PUT("ciphers/{cipherId}/unarchive")
suspend fun unarchiveCipher(
@Path("cipherId") cipherId: String,
): NetworkResult<Unit>
/**
* Create a cipher.
*/
@@ -21,6 +21,16 @@ import java.io.File
*/
@Suppress("TooManyFunctions")
interface CiphersService {
/**
* Attempt to archive a cipher.
*/
suspend fun archiveCipher(cipherId: String): Result<Unit>
/**
* Attempt to unarchive a cipher.
*/
suspend fun unarchiveCipher(cipherId: String): Result<Unit>
/**
* Attempt to create a cipher.
*/
@@ -39,6 +39,18 @@ internal class CiphersServiceImpl(
private val json: Json,
private val clock: Clock,
) : CiphersService {
override suspend fun archiveCipher(
cipherId: String,
): Result<Unit> = ciphersApi
.archiveCipher(cipherId = cipherId)
.toResult()
override suspend fun unarchiveCipher(
cipherId: String,
): Result<Unit> = ciphersApi
.unarchiveCipher(cipherId = cipherId)
.toResult()
override suspend fun createCipher(
body: CipherJsonRequest,
): Result<CreateCipherResponseJson> =
@@ -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))