[PM-44192] fix: Keep server cipher data after vault migration (#7432)

This commit is contained in:
Bernd Schoolmann
2026-10-02 03:50:27 +00:00
committed by GitHub
parent 469f22575a
commit 64cfaad068
5 changed files with 133 additions and 33 deletions
@@ -28,7 +28,6 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
import com.x8bit.bitwarden.data.vault.repository.util.updateFromMiniResponse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -298,8 +297,8 @@ class VaultMigrationManagerImpl(
encryptedCiphersMap[miniResponse.id]?.let {
vaultDiskSource.saveCipher(
userId = userId,
cipher = it.updateFromMiniResponse(
miniResponse = miniResponse,
cipher = miniResponse.toEncryptedNetworkCipher(
originalCipher = it,
collectionIds = collectionIds,
),
)
@@ -125,29 +125,51 @@ fun Cipher.toEncryptedNetworkCipherResponse(
)
/**
* Updates a [SyncResponseJson.Cipher] with metadata from a
* [CipherMiniResponseJson.CipherMiniResponse].
* Converts a [CipherMiniResponseJson.CipherMiniResponse] into a [SyncResponseJson.Cipher],
* taking fields the mini response lacks (folder, favorite, edit, permissions, view password,
* encrypted for) from [originalCipher].
* This is useful for updating local cipher data after bulk operations that return mini responses.
*
* @param miniResponse The mini response containing updated cipher metadata.
* @param collectionIds Optional list of collection IDs to update.
* If null, keeps existing collection IDs.
* @return A new [SyncResponseJson.Cipher] with updated fields from the mini response.
* @param originalCipher The local cipher to take fields missing from the mini response from.
* @param collectionIds Optional list of collection IDs to set.
* If null, keeps the collection IDs of [originalCipher].
* @return A new [SyncResponseJson.Cipher] built from the mini response.
*/
fun SyncResponseJson.Cipher.updateFromMiniResponse(
miniResponse: CipherMiniResponseJson.CipherMiniResponse,
fun CipherMiniResponseJson.CipherMiniResponse.toEncryptedNetworkCipher(
originalCipher: SyncResponseJson.Cipher,
collectionIds: List<String>? = null,
): SyncResponseJson.Cipher = copy(
organizationId = miniResponse.organizationId,
collectionIds = collectionIds ?: this.collectionIds,
revisionDate = miniResponse.revisionDate,
key = miniResponse.key,
attachments = miniResponse.attachments,
archivedDate = miniResponse.archivedDate,
deletedDate = miniResponse.deletedDate,
reprompt = miniResponse.reprompt,
shouldOrganizationUseTotp = miniResponse.shouldOrganizationUseTotp,
type = miniResponse.type,
): SyncResponseJson.Cipher = SyncResponseJson.Cipher(
id = id,
organizationId = organizationId,
folderId = originalCipher.folderId,
collectionIds = collectionIds ?: originalCipher.collectionIds,
key = key,
name = name,
notes = notes,
type = type,
login = login,
identity = identity,
card = card,
secureNote = secureNote,
sshKey = sshKey,
bankAccount = bankAccount,
driversLicense = driversLicense,
passport = passport,
isFavorite = originalCipher.isFavorite,
reprompt = reprompt,
shouldOrganizationUseTotp = shouldOrganizationUseTotp,
shouldEdit = originalCipher.shouldEdit,
permissions = originalCipher.permissions,
shouldViewPassword = originalCipher.shouldViewPassword,
encryptedFor = originalCipher.encryptedFor,
attachments = attachments,
fields = fields,
passwordHistory = passwordHistory,
creationDate = creationDate,
deletedDate = deletedDate,
revisionDate = revisionDate,
archivedDate = archivedDate,
data = data,
)
/**
@@ -554,7 +554,7 @@ class VaultSdkCipherExtensionsTest {
}
@Test
fun `updateFromMiniResponse should update cipher with mini response data`() {
fun `toEncryptedNetworkCipher from mini response should use mini response data`() {
val originalCipher = createMockCipher(
number = 1,
organizationId = null,
@@ -563,8 +563,8 @@ class VaultSdkCipherExtensionsTest {
val miniResponse = createMockCipherMiniResponse(number = 2)
val result = originalCipher.updateFromMiniResponse(
miniResponse = miniResponse,
val result = miniResponse.toEncryptedNetworkCipher(
originalCipher = originalCipher,
collectionIds = listOf("collection-1"),
)
@@ -577,14 +577,32 @@ class VaultSdkCipherExtensionsTest {
assertEquals(miniResponse.deletedDate, result.deletedDate)
assertEquals(miniResponse.reprompt, result.reprompt)
assertEquals(miniResponse.shouldOrganizationUseTotp, result.shouldOrganizationUseTotp)
// Verify unchanged fields remain the same
assertEquals(originalCipher.name, result.name)
assertEquals(originalCipher.notes, result.notes)
assertEquals(originalCipher.id, result.id)
assertEquals(miniResponse.data, result.data)
assertEquals(miniResponse.creationDate, result.creationDate)
assertEquals(miniResponse.name, result.name)
assertEquals(miniResponse.notes, result.notes)
assertEquals(miniResponse.login, result.login)
assertEquals(miniResponse.card, result.card)
assertEquals(miniResponse.identity, result.identity)
assertEquals(miniResponse.secureNote, result.secureNote)
assertEquals(miniResponse.sshKey, result.sshKey)
assertEquals(miniResponse.bankAccount, result.bankAccount)
assertEquals(miniResponse.driversLicense, result.driversLicense)
assertEquals(miniResponse.passport, result.passport)
assertEquals(miniResponse.fields, result.fields)
assertEquals(miniResponse.passwordHistory, result.passwordHistory)
assertEquals(miniResponse.id, result.id)
// Verify fields absent from the mini response remain the same
assertEquals(originalCipher.folderId, result.folderId)
assertEquals(originalCipher.isFavorite, result.isFavorite)
assertEquals(originalCipher.shouldEdit, result.shouldEdit)
assertEquals(originalCipher.permissions, result.permissions)
assertEquals(originalCipher.shouldViewPassword, result.shouldViewPassword)
assertEquals(originalCipher.encryptedFor, result.encryptedFor)
}
@Test
fun `updateFromMiniResponse should preserve existing collectionIds when not provided`() {
fun `toEncryptedNetworkCipher from mini response should keep collectionIds when null`() {
val originalCipher = createMockCipher(
number = 1,
collectionIds = listOf("original-collection-1", "original-collection-2"),
@@ -592,8 +610,8 @@ class VaultSdkCipherExtensionsTest {
val miniResponse = createMockCipherMiniResponse(number = 2)
val result = originalCipher.updateFromMiniResponse(
miniResponse = miniResponse,
val result = miniResponse.toEncryptedNetworkCipher(
originalCipher = originalCipher,
collectionIds = null,
)
@@ -7,7 +7,8 @@ import java.time.Instant
/**
* Represents a minimal cipher response from the API, typically returned from bulk operations.
* Contains core cipher metadata without detailed type-specific fields.
* Contains cipher metadata and encrypted fields, but not user-specific fields (folder,
* favorite, permissions).
*
* @property cipherMiniResponse The list of mini responses.
*/
@@ -29,6 +30,18 @@ data class CipherMiniResponseJson(
* @property reprompt The reprompt type.
* @property key The cipher key (nullable).
* @property archivedDate The archived date (nullable).
* @property name The encrypted name (nullable, `null` for blob-encrypted ciphers).
* @property notes The encrypted notes (nullable).
* @property login The login data (nullable).
* @property card The card data (nullable).
* @property identity The identity data (nullable).
* @property secureNote The secure note data (nullable).
* @property sshKey The SSH key data (nullable).
* @property bankAccount The bank account data (nullable).
* @property driversLicense The driver's license data (nullable).
* @property passport The passport data (nullable).
* @property fields List of custom fields (nullable).
* @property passwordHistory List of password history entries (nullable).
*/
@Serializable
data class CipherMiniResponse(
@@ -71,5 +84,41 @@ data class CipherMiniResponseJson(
@SerialName("archivedDate")
@Contextual
val archivedDate: Instant?,
@SerialName("name")
val name: String?,
@SerialName("notes")
val notes: String?,
@SerialName("login")
val login: SyncResponseJson.Cipher.Login?,
@SerialName("card")
val card: SyncResponseJson.Cipher.Card?,
@SerialName("identity")
val identity: SyncResponseJson.Cipher.Identity?,
@SerialName("secureNote")
val secureNote: SyncResponseJson.Cipher.SecureNote?,
@SerialName("sshKey")
val sshKey: SyncResponseJson.Cipher.SshKey?,
@SerialName("bankAccount")
val bankAccount: SyncResponseJson.Cipher.BankAccount?,
@SerialName("driversLicense")
val driversLicense: SyncResponseJson.Cipher.DriversLicense?,
@SerialName("passport")
val passport: SyncResponseJson.Cipher.Passport?,
@SerialName("fields")
val fields: List<SyncResponseJson.Cipher.Field>?,
@SerialName("passwordHistory")
val passwordHistory: List<SyncResponseJson.Cipher.PasswordHistory>?,
)
}
@@ -20,6 +20,18 @@ fun createMockCipherMiniResponse(
reprompt = CipherRepromptTypeJson.NONE,
key = "mockKey-$number",
archivedDate = null,
name = "mockName-$number",
notes = "mockNotes-$number",
login = createMockLogin(number = number),
card = createMockCard(number = number),
identity = createMockIdentity(number = number),
secureNote = createMockSecureNote(),
sshKey = createMockSshKey(number = number),
bankAccount = createMockBankAccount(number = number),
driversLicense = createMockDriversLicense(number = number),
passport = createMockPassport(number = number),
fields = listOf(createMockField(number = number)),
passwordHistory = listOf(createMockPasswordHistory(number = number)),
)
/**