diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/sync/v2/bitwarden/ops/CipherSyncOps.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/sync/v2/bitwarden/ops/CipherSyncOps.kt index ed8dda844..71440715c 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/sync/v2/bitwarden/ops/CipherSyncOps.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/sync/v2/bitwarden/ops/CipherSyncOps.kt @@ -44,6 +44,7 @@ import com.artemchep.keyguard.provider.bitwarden.api.builder.put import com.artemchep.keyguard.provider.bitwarden.api.builder.renew import com.artemchep.keyguard.provider.bitwarden.api.builder.restore import com.artemchep.keyguard.provider.bitwarden.api.builder.trash +import com.artemchep.keyguard.provider.bitwarden.api.builder.unarchive import com.artemchep.keyguard.provider.bitwarden.api.merge import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCr import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrCta @@ -58,6 +59,7 @@ import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherAttachment import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherDeleteRequest import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherRestoreRequest import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherUpdate +import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherUnarchiveRequest import com.artemchep.keyguard.provider.bitwarden.entity.request.of import com.artemchep.keyguard.provider.bitwarden.sync.v2.CipherConflictResolution import com.artemchep.keyguard.provider.bitwarden.sync.v2.bitwarden.BitwardenSyncDiagnostics @@ -91,11 +93,12 @@ import kotlin.time.Instant * **three-way merge** via [resolveCipherConflict], * and **bulk server operations** ([BulkRemoteOps]). * - * **Push flow** for modifications (restore → PUT → trash → GET): + * **Push flow** for modifications (restore → PUT → unarchive → trash → GET): * 1. If the cipher was trashed, restore it first. * 2. If data changed, PUT the updated cipher. - * 3. If the cipher should be trashed, trash it. - * 4. Final GET to refresh local state. + * 3. If the cipher should be unarchived, unarchive it. + * 4. If the cipher should be trashed, trash it. + * 5. Final GET to refresh local state. * * Intermediate server responses are captured as [partialRemoteLocal] * so that metadata can be preserved even if a later step fails. @@ -335,6 +338,7 @@ class CipherSyncOps( pushCipherUpdate( update = update, local = local, + server = server, force = force, updatePartialRemoteLocal = { partialRemoteLocal = it }, ) @@ -434,6 +438,7 @@ class CipherSyncOps( private suspend fun pushCipherUpdate( update: CipherUpdate, local: BitwardenCipher, + server: CipherEntity?, force: Boolean, updatePartialRemoteLocal: (BitwardenCipher) -> Unit, ): CipherEntity = @@ -442,6 +447,7 @@ class CipherSyncOps( pushModifiedCipher( update = update, local = local, + server = server, force = force, updatePartialRemoteLocal = updatePartialRemoteLocal, ) @@ -456,6 +462,7 @@ class CipherSyncOps( private suspend fun pushModifiedCipher( update: CipherUpdate.Modify, local: BitwardenCipher, + server: CipherEntity?, force: Boolean, updatePartialRemoteLocal: (BitwardenCipher) -> Unit, ): CipherEntity { @@ -477,8 +484,9 @@ class CipherSyncOps( val isTrashed = update.source.deletedDate != null val wasTrashed = update.source.service.remote?.deletedDate != null + val shouldUnarchive = server?.archivedDate != null && update.source.archivedDate == null val hasChanged = update.hasChanged(force) - if (isTrashed == wasTrashed && !hasChanged) { + if (isTrashed == wasTrashed && !shouldUnarchive && !hasChanged) { return getCipher(cipherApi) } @@ -505,10 +513,18 @@ class CipherSyncOps( null } + if (putCipher != null && (shouldUnarchive || isTrashed)) { + handleIntermediateResponse(putCipher) + } + if (shouldUnarchive) { + ciphersApi.unarchive( + httpClient = httpClient, + env = env, + token = token, + body = CipherUnarchiveRequest(ids = listOf(update.cipherId)), + ) + } if (isTrashed) { - if (putCipher != null) { - handleIntermediateResponse(putCipher) - } trashCipher(cipherApi) } return getCipher(cipherApi) diff --git a/common/src/desktopTest/kotlin/com/artemchep/keyguard/provider/bitwarden/sync/v2/SyncV2CipherUploadIntegrationTest.kt b/common/src/desktopTest/kotlin/com/artemchep/keyguard/provider/bitwarden/sync/v2/SyncV2CipherUploadIntegrationTest.kt index 36432cc4f..2f8895e1a 100644 --- a/common/src/desktopTest/kotlin/com/artemchep/keyguard/provider/bitwarden/sync/v2/SyncV2CipherUploadIntegrationTest.kt +++ b/common/src/desktopTest/kotlin/com/artemchep/keyguard/provider/bitwarden/sync/v2/SyncV2CipherUploadIntegrationTest.kt @@ -37,6 +37,7 @@ import com.artemchep.keyguard.provider.bitwarden.entity.AttachmentEntity import com.artemchep.keyguard.provider.bitwarden.entity.CipherEntity import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherAttachmentCreateRequest import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherRequest +import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherUnarchiveRequest import com.artemchep.keyguard.provider.bitwarden.sync.v2.bitwarden.SyncByBitwardenTokenV2Impl import com.artemchep.keyguard.provider.bitwarden.sync.v2.core.EntityTypeOutcome import com.artemchep.keyguard.provider.bitwarden.sync.v2.bitwarden.ops.CipherSyncOps @@ -2458,6 +2459,200 @@ class SyncV2CipherUploadIntegrationTest { assertEquals(emptyList(), fixture.database.cipherQueries.getByAccountId(ACCOUNT_ID).executeAsList()) } + @Test + fun `production CipherSyncOps restores archived cipher through unarchive endpoint`() = runTest { + val server = UploadTestServer() + val fixture = createProductionCipherOpsFixture(server) + val remote = + testCipher( + localId = "cipher-local-1", + remoteId = "cipher-remote-1", + localRevisionDate = T0, + remoteRevisionDate = T0, + attachments = emptyList(), + ).copy( + keyBase64 = fixture.cipherKeyBase64(), + archivedDate = T0, + ) + val local = remote.copy(archivedDate = null) + server.seedCipher( + remote.toEncryptedCipherEntity( + crypto = fixture.crypto, + base64Service = fixture.base64Service, + ), + ) + + val outcome = assertIs>( + fixture.ops.pushToServer( + local = local, + server = server.ciphers.getValue("cipher-remote-1"), + force = false, + ), + ) + + assertNull(outcome.local.archivedDate) + val unarchiveRequest = server.requests.first { it.path == "/api/ciphers/unarchive" } + assertEquals( + listOf("cipher-remote-1"), + UploadTestServer.json.decodeFromString(unarchiveRequest.body).ids, + ) + assertEquals( + listOf( + HttpMethod.Put to "/api/ciphers/unarchive", + HttpMethod.Get to "/api/ciphers/cipher-remote-1", + ), + server.requests.map { it.method to it.path }, + ) + } + + @Test + fun `production CipherSyncOps puts changed cipher before unarchiving`() = runTest { + val server = UploadTestServer() + server.cipherPutAppliesRequestBody = true + val fixture = createProductionCipherOpsFixture(server) + val remote = + testCipher( + localId = "cipher-local-1", + remoteId = "cipher-remote-1", + localRevisionDate = T0, + remoteRevisionDate = T0, + attachments = emptyList(), + ).copy( + keyBase64 = fixture.cipherKeyBase64(), + name = "Archived Cipher", + archivedDate = T0, + ) + val local = remote.copy( + name = "Restored Cipher", + revisionDate = T2, + archivedDate = null, + ) + server.seedCipher( + remote.toEncryptedCipherEntity( + crypto = fixture.crypto, + base64Service = fixture.base64Service, + ), + ) + + val outcome = assertIs>( + fixture.ops.pushToServer( + local = local, + server = server.ciphers.getValue("cipher-remote-1"), + force = false, + ), + ) + + assertEquals("Restored Cipher", outcome.local.name) + assertNull(outcome.local.archivedDate) + val unarchiveRequest = server.requests.first { it.path == "/api/ciphers/unarchive" } + assertEquals( + listOf("cipher-remote-1"), + UploadTestServer.json.decodeFromString(unarchiveRequest.body).ids, + ) + assertEquals( + listOf( + HttpMethod.Put to "/api/ciphers/cipher-remote-1", + HttpMethod.Put to "/api/ciphers/unarchive", + HttpMethod.Get to "/api/ciphers/cipher-remote-1", + ), + server.requests.map { it.method to it.path }, + ) + } + + @Test + fun `production CipherSyncOps skips unarchive when archive states match`() = runTest { + val server = UploadTestServer() + val fixture = createProductionCipherOpsFixture(server) + val local = + testCipher( + localId = "cipher-local-1", + remoteId = "cipher-remote-1", + localRevisionDate = T0, + remoteRevisionDate = T0, + attachments = emptyList(), + ).copy( + keyBase64 = fixture.cipherKeyBase64(), + archivedDate = T0, + ) + server.seedCipher( + local.toEncryptedCipherEntity( + crypto = fixture.crypto, + base64Service = fixture.base64Service, + ), + ) + + val outcome = assertIs>( + fixture.ops.pushToServer( + local = local, + server = server.ciphers.getValue("cipher-remote-1"), + force = false, + ), + ) + + assertEquals(T0, outcome.local.archivedDate) + assertEquals( + listOf(HttpMethod.Get to "/api/ciphers/cipher-remote-1"), + server.requests.map { it.method to it.path }, + ) + } + + @Test + fun `production CipherSyncOps keeps put partial when unarchive fails`() = runTest { + val server = UploadTestServer() + val fixture = createProductionCipherOpsFixture(server) + val remote = + testCipher( + localId = "cipher-local-1", + remoteId = "cipher-remote-1", + localRevisionDate = T0, + remoteRevisionDate = T0, + attachments = emptyList(), + ).copy( + keyBase64 = fixture.cipherKeyBase64(), + name = "Archived Cipher", + archivedDate = T0, + ) + val local = remote.copy( + name = "Restored Cipher", + revisionDate = T2, + archivedDate = null, + ) + server.seedCipher( + remote.toEncryptedCipherEntity( + crypto = fixture.crypto, + base64Service = fixture.base64Service, + ), + ) + server.nextCipherUnarchiveFailure = HttpStatusCode.InternalServerError + + val error = assertCipherFailure { + fixture.ops.pushToServer( + local = local, + server = server.ciphers.getValue("cipher-remote-1"), + force = false, + ) + } + + val partial = assertIs(error.partialRemoteLocal) + assertEquals(T4, partial.service.remote?.revisionDate) + assertEquals(T0, partial.archivedDate) + assertEquals(T0, server.ciphers.getValue("cipher-remote-1").archivedDate) + val httpException = assertIs(error.cause) + assertEquals(HttpStatusCode.InternalServerError, httpException.statusCode) + val unarchiveRequest = server.requests.first { it.path == "/api/ciphers/unarchive" } + assertEquals( + listOf("cipher-remote-1"), + UploadTestServer.json.decodeFromString(unarchiveRequest.body).ids, + ) + assertEquals( + listOf( + HttpMethod.Put to "/api/ciphers/cipher-remote-1", + HttpMethod.Put to "/api/ciphers/unarchive", + ), + server.requests.map { it.method to it.path }, + ) + } + @Test fun `production CipherSyncOps restores legacy trashed cipher before uploading generated item key`() = runTest { val server = UploadTestServer() diff --git a/common/src/desktopTest/kotlin/com/artemchep/keyguard/provider/bitwarden/sync/v2/SyncV2UploadTestFixtures.kt b/common/src/desktopTest/kotlin/com/artemchep/keyguard/provider/bitwarden/sync/v2/SyncV2UploadTestFixtures.kt index a36388983..f5f54a0fa 100644 --- a/common/src/desktopTest/kotlin/com/artemchep/keyguard/provider/bitwarden/sync/v2/SyncV2UploadTestFixtures.kt +++ b/common/src/desktopTest/kotlin/com/artemchep/keyguard/provider/bitwarden/sync/v2/SyncV2UploadTestFixtures.kt @@ -84,6 +84,7 @@ import com.artemchep.keyguard.provider.bitwarden.crypto.transform import com.artemchep.keyguard.provider.bitwarden.entity.AttachmentEntity import com.artemchep.keyguard.provider.bitwarden.entity.CipherAttachmentUploadEntity import com.artemchep.keyguard.provider.bitwarden.entity.CipherEntity +import com.artemchep.keyguard.provider.bitwarden.entity.CipherListEntity import com.artemchep.keyguard.provider.bitwarden.entity.CipherTypeEntity import com.artemchep.keyguard.provider.bitwarden.entity.CollectionEntity import com.artemchep.keyguard.provider.bitwarden.entity.ConnectTokenResponse @@ -108,6 +109,7 @@ import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherAttachment import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherCreateRequest import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherDeleteRequest import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherRequest +import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherUnarchiveRequest import com.artemchep.keyguard.provider.bitwarden.entity.request.SendRequest import com.artemchep.keyguard.provider.bitwarden.upload.FailingPendingUploadCoordinator import com.artemchep.keyguard.provider.bitwarden.upload.PendingUploadCoordinator @@ -169,6 +171,7 @@ internal class UploadTestServer { var nextCipherGetFailure: HttpStatusCode? = null var cipherGetFailureAfterSuccessfulGets: Int? = null var nextCipherPutFailure: HttpStatusCode? = null + var nextCipherUnarchiveFailure: HttpStatusCode? = null var nextCipherTrashFailure: HttpStatusCode? = null var nextCipherBulkDeleteFailure: HttpStatusCode? = null var nextCipherTrashException: Throwable? = null @@ -296,6 +299,27 @@ internal class UploadTestServer { respondText("") } + request.method == HttpMethod.Put && + request.path == "/api/ciphers/unarchive" -> { + val failure = nextCipherUnarchiveFailure + if (failure != null) { + nextCipherUnarchiveFailure = null + respondApiError( + status = failure, + description = "cipher unarchive failed", + ) + } else { + val body = json.decodeFromString(request.body) + val unarchived = body.ids.map { cipherId -> + requireNotNull(ciphers[cipherId]).copy( + revisionDate = T4, + archivedDate = null, + ).also { ciphers[cipherId] = it } + } + respondJson(CipherListEntity(data = unarchived)) + } + } + request.method == HttpMethod.Delete && request.path == "/api/ciphers/" -> { val failure = nextCipherBulkDeleteFailure