Skip to main content

bitwarden_vault/cipher/cipher_client/
edit.rs

1use bitwarden_api_api::models::{
2    CipherCollectionsRequestModel, CipherPartialRequestModel, CipherRequestModel,
3};
4use bitwarden_collections::collection::CollectionId;
5use bitwarden_core::{
6    ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId,
7    key_management::KeySlotIds, require,
8};
9use bitwarden_crypto::{CryptoError, IdentifyKey, KeyStore, SymmetricCryptoKey};
10use bitwarden_error::bitwarden_error;
11use bitwarden_state::repository::{Repository, RepositoryError};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use super::CiphersClient;
17use crate::{
18    AttachmentView, Cipher, CipherId, CipherRepromptType, CipherType, CipherView, FieldView,
19    FolderId, ItemNotFoundError, VaultParseError,
20    cipher::cipher::{EncryptMode, PartialCipher, StrictDecrypt},
21    cipher_view_type::CipherViewType,
22};
23
24#[allow(missing_docs)]
25#[bitwarden_error(flat)]
26#[derive(Debug, Error)]
27pub enum EditCipherError {
28    #[error(transparent)]
29    ItemNotFound(#[from] ItemNotFoundError),
30    #[error(transparent)]
31    Crypto(#[from] CryptoError),
32    #[error(transparent)]
33    Api(#[from] ApiError),
34    #[error(transparent)]
35    VaultParse(#[from] VaultParseError),
36    #[error(transparent)]
37    MissingField(#[from] MissingFieldError),
38    #[error(transparent)]
39    NotAuthenticated(#[from] NotAuthenticatedError),
40    #[error(transparent)]
41    Repository(#[from] RepositoryError),
42    #[error(transparent)]
43    Uuid(#[from] uuid::Error),
44    /// The stored cipher is PAM-gated, so local state holds only its partial copy and an edit
45    /// built on it would drop the item's password history and mis-stamp its revision date.
46    #[error(
47        "Cannot edit a PAM-gated cipher from local state; use `edit_gated` with a full original \
48         obtained under an active lease"
49    )]
50    GatedCipher,
51    /// `edit_gated` was handed a partial view as the original.
52    #[error("Editing a PAM-gated cipher requires a full original; the supplied view is partial")]
53    PartialOriginal,
54}
55
56/// Request to edit a cipher.
57#[derive(Clone, Serialize, Deserialize, Debug)]
58#[serde(rename_all = "camelCase")]
59#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
60#[bitwarden_ffi::wasm_record]
61pub struct CipherEditRequest {
62    /// ID of the cipher to edit.
63    pub id: CipherId,
64
65    /// Organization that owns the cipher, or `None` for the user's individual vault.
66    pub organization_id: Option<OrganizationId>,
67    /// Folder to place the cipher in, if any.
68    pub folder_id: Option<FolderId>,
69    /// Whether the cipher is marked as a favorite.
70    pub favorite: bool,
71    /// Whether the user must re-enter their master password to view the cipher.
72    pub reprompt: CipherRepromptType,
73    /// Display name of the cipher.
74    pub name: String,
75    /// Free-form notes.
76    pub notes: Option<String>,
77    /// Custom fields.
78    pub fields: Vec<FieldView>,
79    /// Type of the cipher and its type-specific data.
80    pub r#type: CipherViewType,
81    /// Revision date of the cipher being edited, as last seen by the client.
82    pub revision_date: DateTime<Utc>,
83    /// When the cipher was archived, or `None` if it is not archived.
84    pub archived_date: Option<DateTime<Utc>>,
85    /// Attachments on the cipher.
86    pub attachments: Vec<AttachmentView>,
87    /// The cipher's own encryption key, if it has one.
88    #[cfg_attr(feature = "wasm", tsify(type = "SymmetricKey | undefined"))]
89    pub key: Option<SymmetricCryptoKey>,
90}
91
92impl TryFrom<CipherView> for CipherEditRequest {
93    type Error = MissingFieldError;
94
95    fn try_from(value: CipherView) -> Result<Self, Self::Error> {
96        let type_data = match value.r#type {
97            CipherType::Login => value.login.map(CipherViewType::Login),
98            CipherType::SecureNote => value.secure_note.map(CipherViewType::SecureNote),
99            CipherType::Card => value.card.map(CipherViewType::Card),
100            CipherType::Identity => value.identity.map(CipherViewType::Identity),
101            CipherType::SshKey => value.ssh_key.map(CipherViewType::SshKey),
102            CipherType::BankAccount => value.bank_account.map(CipherViewType::BankAccount),
103            CipherType::DriversLicense => value.drivers_license.map(CipherViewType::DriversLicense),
104            CipherType::Passport => value.passport.map(CipherViewType::Passport),
105        };
106        Ok(Self {
107            id: value.id.ok_or(MissingFieldError("id"))?,
108            organization_id: value.organization_id,
109            folder_id: value.folder_id,
110            favorite: value.favorite,
111            reprompt: value.reprompt,
112            key: value.key,
113            name: value.name,
114            notes: value.notes,
115            fields: value.fields.unwrap_or_default(),
116            r#type: require!(type_data),
117            attachments: value.attachments.unwrap_or_default(),
118            revision_date: value.revision_date,
119            archived_date: value.archived_date,
120        })
121    }
122}
123
124/// Request to update the subset of cipher fields that a user without edit
125/// permissions is still allowed to change (`folder_id` and `favorite`).
126///
127/// Backed by the `PUT /ciphers/{id}/partial` server endpoint, which authorizes
128/// based on view (not edit) access.
129#[derive(Clone, Serialize, Deserialize, Debug)]
130#[serde(rename_all = "camelCase")]
131#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
132#[bitwarden_ffi::wasm_record]
133pub struct CipherPartialEditRequest {
134    /// ID of the cipher to edit.
135    pub id: CipherId,
136    /// Folder to place the cipher in, if any.
137    pub folder_id: Option<FolderId>,
138    /// Whether the cipher is marked as a favorite.
139    pub favorite: bool,
140}
141
142/// Internal helper to convert a [`CipherEditRequest`] into a [`CipherView`]
143/// so the existing `CipherView` encryption pipeline can be reused.
144///
145/// This conversion is lossy and intended for use only within the edit flow,
146/// as the `CipherView` produced will not have all fields populated (e.g. `collection_ids`).
147pub(crate) fn convert_request_to_cipher_view(r: CipherEditRequest) -> CipherView {
148    CipherView {
149        partial: false,
150        id: Some(r.id),
151        organization_id: r.organization_id,
152        folder_id: r.folder_id,
153        // `collection_ids` is empty because collections are updated via a separate endpoint.
154        collection_ids: vec![],
155        key: r.key,
156        name: r.name,
157        notes: r.notes,
158        r#type: r.r#type.get_cipher_type(),
159        login: r.r#type.as_login_view().cloned(),
160        identity: r.r#type.as_identity_view().cloned(),
161        card: r.r#type.as_card_view().cloned(),
162        secure_note: r.r#type.as_secure_note_view().cloned(),
163        ssh_key: r.r#type.as_ssh_key_view().cloned(),
164        bank_account: r.r#type.as_bank_account_view().cloned(),
165        drivers_license: r.r#type.as_drivers_license_view().cloned(),
166        passport: r.r#type.as_passport_view().cloned(),
167        favorite: r.favorite,
168        reprompt: r.reprompt,
169        organization_use_totp: false,
170        edit: true,
171        permissions: None,
172        view_password: true,
173        local_data: None,
174        attachments: Some(r.attachments),
175        attachment_decryption_failures: None,
176        fields: Some(r.fields),
177        password_history: None,
178        // `creation_date` is overwritten by the server on merge
179        creation_date: Utc::now(),
180        deleted_date: None,
181        revision_date: r.revision_date,
182        archived_date: r.archived_date,
183    }
184}
185
186async fn edit_cipher<R: Repository<Cipher> + ?Sized>(
187    key_store: &KeyStore<KeySlotIds>,
188    api_client: &bitwarden_api_api::apis::ApiClient,
189    repository: &R,
190    encrypted_for: UserId,
191    request: CipherEditRequest,
192    use_strict_decryption: bool,
193    use_blob: bool,
194) -> Result<CipherView, EditCipherError> {
195    let cipher_id = request.id;
196
197    let original_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
198
199    // A PAM-gated cipher is only ever stored partial — the server withholds its secrets from
200    // every bulk read and from write-returns alike — so the original below would carry blanks
201    // where the withheld fields belong. `update_password_history` would then drop the item's
202    // whole history (a partial view has none to chain) and stamp a fresh `password_revision_date`,
203    // and the PUT would persist both. Refuse rather than corrupt; `edit_gated` takes the full
204    // original from a lease-authorised read.
205    if original_cipher.partial_data.is_some() {
206        return Err(EditCipherError::GatedCipher);
207    }
208
209    let original_cipher_view: CipherView = if use_strict_decryption {
210        key_store.decrypt(&StrictDecrypt(original_cipher.clone()))?
211    } else {
212        key_store.decrypt(&original_cipher)?
213    };
214
215    submit_cipher_edit(
216        key_store,
217        api_client,
218        repository,
219        encrypted_for,
220        request,
221        &original_cipher_view,
222        original_cipher,
223        use_strict_decryption,
224        use_blob,
225    )
226    .await
227}
228
229/// The body both edit paths share: request → view, fold in password history against
230/// `original_cipher_view`, encrypt, PUT, and merge the write-return over `stored_cipher` before
231/// persisting it.
232///
233/// The paths differ only in where the original comes from — local state for [`edit_cipher`], the
234/// caller for [`edit_gated_cipher`], because a gated cipher has no full copy in state — so that is
235/// all either one is left holding.
236// `use_strict_decryption` and `use_blob` are
237// short-lived feature-rollout flags that will be removed once their migrations
238// complete, at which point the argument count drops back under the limit.
239#[allow(clippy::too_many_arguments)]
240async fn submit_cipher_edit<R: Repository<Cipher> + ?Sized>(
241    key_store: &KeyStore<KeySlotIds>,
242    api_client: &bitwarden_api_api::apis::ApiClient,
243    repository: &R,
244    encrypted_for: UserId,
245    request: CipherEditRequest,
246    original_cipher_view: &CipherView,
247    stored_cipher: Cipher,
248    use_strict_decryption: bool,
249    use_blob: bool,
250) -> Result<CipherView, EditCipherError> {
251    let cipher_id = request.id;
252    let stored_gated = stored_cipher.partial_data.is_some();
253
254    let mut view: CipherView = convert_request_to_cipher_view(request);
255    view.update_password_history(original_cipher_view);
256
257    let encrypted_by_key_id = key_store
258        .context()
259        .get_symmetric_key_id(view.key_identifier())
260        .map(|id| id.to_string());
261
262    let mode = if use_blob {
263        EncryptMode::Blob(view)
264    } else {
265        EncryptMode::Legacy(view)
266    };
267
268    let cipher: Cipher = key_store.encrypt(mode)?;
269    let mut cipher_request: CipherRequestModel = cipher.try_into()?;
270    cipher_request.encrypted_for = Some(encrypted_for.into());
271    cipher_request.encrypted_by_key_id = encrypted_by_key_id;
272
273    let cipher: Cipher = api_client
274        .ciphers_api()
275        .put(cipher_id.into(), Some(cipher_request))
276        .await?
277        .merge_with_cipher(Some(stored_cipher))?;
278    debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
279
280    // Never let a write-return un-gate the stored copy. The server withholds secrets from a gated
281    // write-return, so a full response here should be impossible — but persisting one would put
282    // lease-scoped secrets into durable state, outliving the lease that justified them. Skip the
283    // write and let the next sync reconcile: the server already applied the change, so reporting
284    // a failure would be the worse lie.
285    //
286    // A no-op for [`edit_cipher`], which refuses a gated cipher outright, so nothing it stores is
287    // ever gated to begin with.
288    if !stored_gated || cipher.partial_data.is_some() {
289        repository.set(cipher_id, cipher.clone()).await?;
290    }
291
292    Ok(if use_strict_decryption {
293        key_store.decrypt(&StrictDecrypt(cipher))?
294    } else {
295        key_store.decrypt(&cipher)?
296    })
297}
298
299/// Edit a PAM-gated cipher against a full original supplied by the caller.
300///
301/// [`edit_cipher`] reads its original out of the repository, which for a gated cipher only ever
302/// holds the partial copy. This path takes the full view the caller obtained from a
303/// lease-authorised single-cipher read instead, so password history carries forward correctly.
304/// It mirrors the admin edit path, which takes its original as an argument for the same reason:
305/// no usable copy exists in local state.
306// `use_strict_decryption` and `use_blob` are
307// short-lived feature-rollout flags that will be removed once their migrations
308// complete, at which point the argument count drops back under the limit.
309#[allow(clippy::too_many_arguments)]
310async fn edit_gated_cipher<R: Repository<Cipher> + ?Sized>(
311    key_store: &KeyStore<KeySlotIds>,
312    api_client: &bitwarden_api_api::apis::ApiClient,
313    repository: &R,
314    encrypted_for: UserId,
315    request: CipherEditRequest,
316    original_cipher_view: CipherView,
317    use_strict_decryption: bool,
318    use_blob: bool,
319) -> Result<CipherView, EditCipherError> {
320    let cipher_id = request.id;
321
322    // The point of this path is the full original. A partial one lands us back in exactly the
323    // case `edit_cipher` refuses, only with the blanks handed in by the caller.
324    if original_cipher_view.partial {
325        return Err(EditCipherError::PartialOriginal);
326    }
327
328    // Read for `local_data`, which the response model does not carry, and for the gating the
329    // shared body checks before it persists the write-return.
330    let stored_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
331
332    submit_cipher_edit(
333        key_store,
334        api_client,
335        repository,
336        encrypted_for,
337        request,
338        &original_cipher_view,
339        stored_cipher,
340        use_strict_decryption,
341        use_blob,
342    )
343    .await
344}
345
346/// Update only the cipher fields available to users without edit permissions
347/// (`folder_id` and `favorite`) via the server's partial-update endpoint.
348async fn partial_edit_cipher<R: Repository<Cipher> + ?Sized>(
349    key_store: &KeyStore<KeySlotIds>,
350    api_client: &bitwarden_api_api::apis::ApiClient,
351    repository: &R,
352    request: CipherPartialEditRequest,
353    use_strict_decryption: bool,
354) -> Result<CipherView, EditCipherError> {
355    let cipher_id = request.id;
356
357    let original_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
358
359    let partial_request = CipherPartialRequestModel {
360        folder_id: request.folder_id.map(|id| id.to_string()),
361        favorite: Some(request.favorite),
362    };
363
364    let cipher: Cipher = api_client
365        .ciphers_api()
366        .put_partial(cipher_id.into(), Some(partial_request))
367        .await?
368        .merge_with_cipher(Some(original_cipher))?;
369    debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
370    repository.set(cipher_id, cipher.clone()).await?;
371
372    Ok(if use_strict_decryption {
373        key_store.decrypt(&StrictDecrypt(cipher))?
374    } else {
375        key_store.decrypt(&cipher)?
376    })
377}
378
379#[allow(deprecated)]
380#[bitwarden_ffi::wasm_export]
381impl CiphersClient {
382    /// Edit an existing [Cipher] and save it to the server.
383    pub async fn edit(&self, request: CipherEditRequest) -> Result<CipherView, EditCipherError> {
384        let key_store = self.client.internal.get_key_store();
385        let config = self.client.internal.get_api_configurations();
386        let repository = self.get_repository()?;
387
388        let user_id = self
389            .client
390            .internal
391            .get_user_id()
392            .ok_or(NotAuthenticatedError)?;
393
394        let use_blob = self.should_use_blob_encryption(request.organization_id);
395
396        edit_cipher(
397            key_store,
398            &config.api_client,
399            repository.as_ref(),
400            user_id,
401            request,
402            self.is_strict_decrypt().await,
403            use_blob,
404        )
405        .await
406    }
407
408    /// Edit a PAM-gated [`Cipher`] whose secrets were revealed under an active lease.
409    ///
410    /// [`CiphersClient::edit`] builds its original from local state, which for a gated cipher only
411    /// ever holds the partial copy, and so refuses. Pass the full view obtained from the
412    /// lease-authorised read as `original_cipher_view` — it is what password history is diffed
413    /// against.
414    ///
415    /// The returned view reflects what was persisted, so it is partial: the server withholds
416    /// secrets from a gated write-return. The caller keeps its own full copy in memory.
417    pub async fn edit_gated(
418        &self,
419        request: CipherEditRequest,
420        original_cipher_view: CipherView,
421    ) -> Result<CipherView, EditCipherError> {
422        let key_store = self.client.internal.get_key_store();
423        let config = self.client.internal.get_api_configurations();
424        let repository = self.get_repository()?;
425
426        let user_id = self
427            .client
428            .internal
429            .get_user_id()
430            .ok_or(NotAuthenticatedError)?;
431
432        let use_blob = self.should_use_blob_encryption(request.organization_id);
433
434        edit_gated_cipher(
435            key_store,
436            &config.api_client,
437            repository.as_ref(),
438            user_id,
439            request,
440            original_cipher_view,
441            self.is_strict_decrypt().await,
442            use_blob,
443        )
444        .await
445    }
446
447    /// Update only `folder_id` and `favorite` on an existing [Cipher].
448    ///
449    /// Intended for users who do not have edit permissions on the cipher, but
450    /// are still allowed to change these personal organization fields.
451    pub async fn edit_partial(
452        &self,
453        request: CipherPartialEditRequest,
454    ) -> Result<CipherView, EditCipherError> {
455        let key_store = self.client.internal.get_key_store();
456        let config = self.client.internal.get_api_configurations();
457        let repository = self.get_repository()?;
458
459        partial_edit_cipher(
460            key_store,
461            &config.api_client,
462            repository.as_ref(),
463            request,
464            self.is_strict_decrypt().await,
465        )
466        .await
467    }
468
469    /// Adds the cipher matched by [CipherId] to any number of collections on the server.
470    pub async fn update_collection(
471        &self,
472        cipher_id: CipherId,
473        collection_ids: Vec<CollectionId>,
474    ) -> Result<CipherView, EditCipherError> {
475        let req = CipherCollectionsRequestModel {
476            collection_ids: collection_ids
477                .into_iter()
478                .map(|id| id.to_string())
479                .collect(),
480        };
481        let repository = self.get_repository()?;
482
483        let api_config = self.client.internal.get_api_configurations();
484        let api = api_config.api_client.ciphers_api();
485        let orig_cipher = repository.get(cipher_id).await?;
486        let cipher_response = api
487            .put_collections_v_next(cipher_id.into(), Some(req))
488            .await?
489            .cipher
490            .map(|c| *c)
491            .ok_or(MissingFieldError("cipher"))?;
492        let cipher: Cipher = cipher_response.merge_with_cipher(orig_cipher)?;
493        repository.set(cipher_id, cipher.clone()).await?;
494
495        Ok(self
496            .decrypt(cipher)
497            .await
498            .map_err(|_| CryptoError::KeyDecrypt)?)
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel};
505    use bitwarden_core::key_management::{
506        SymmetricKeySlotId, create_test_crypto_with_user_and_org_key,
507    };
508    use bitwarden_crypto::{
509        KeyStore, PrimitiveEncryptable, SymmetricCryptoKey, SymmetricKeyAlgorithm,
510    };
511    use bitwarden_test::MemoryRepository;
512    use chrono::TimeZone;
513
514    use super::*;
515    use crate::{
516        Cipher, CipherId, CipherRepromptType, CipherType, FieldType, Login, LoginView,
517        PasswordHistoryView, password_history::MAX_PASSWORD_HISTORY_ENTRIES,
518    };
519
520    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
521    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
522
523    fn generate_test_cipher() -> CipherView {
524        CipherView {
525            partial: false,
526            id: Some(TEST_CIPHER_ID.parse().unwrap()),
527            organization_id: None,
528            folder_id: None,
529            collection_ids: vec![],
530            key: None,
531            name: "Test Login".to_string(),
532            notes: None,
533            r#type: CipherType::Login,
534            login: Some(LoginView {
535                username: Some("[email protected]".to_string()),
536                password: Some("password123".to_string()),
537                password_revision_date: None,
538                uris: None,
539                totp: None,
540                autofill_on_page_load: None,
541                fido2_credentials: None,
542            }),
543            identity: None,
544            card: None,
545            secure_note: None,
546            ssh_key: None,
547            bank_account: None,
548            passport: None,
549            drivers_license: None,
550            favorite: false,
551            reprompt: CipherRepromptType::None,
552            organization_use_totp: true,
553            edit: true,
554            permissions: None,
555            view_password: true,
556            local_data: None,
557            attachments: None,
558            attachment_decryption_failures: None,
559            fields: None,
560            password_history: None,
561            creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
562            deleted_date: None,
563            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
564            archived_date: None,
565        }
566    }
567
568    fn create_test_login_cipher(password: &str) -> CipherView {
569        let mut cipher_view = generate_test_cipher();
570        if let Some(ref mut login) = cipher_view.login {
571            login.password = Some(password.to_string());
572        }
573        cipher_view
574    }
575
576    async fn repository_add_cipher(
577        repository: &MemoryRepository<Cipher>,
578        store: &KeyStore<KeySlotIds>,
579        cipher_id: CipherId,
580        name: &str,
581    ) {
582        let cipher = {
583            let mut ctx = store.context();
584
585            Cipher {
586                partial_data: None,
587                id: Some(cipher_id),
588                organization_id: None,
589                folder_id: None,
590                collection_ids: vec![],
591                key: None,
592                name: Some(name.encrypt(&mut ctx, SymmetricKeySlotId::User).unwrap()),
593                notes: None,
594                r#type: CipherType::Login,
595                login: Some(Login {
596                    username: Some("[email protected]")
597                        .map(|u| u.encrypt(&mut ctx, SymmetricKeySlotId::User))
598                        .transpose()
599                        .unwrap(),
600                    password: Some("password123")
601                        .map(|p| p.encrypt(&mut ctx, SymmetricKeySlotId::User))
602                        .transpose()
603                        .unwrap(),
604                    password_revision_date: None,
605                    uris: None,
606                    totp: None,
607                    autofill_on_page_load: None,
608                    fido2_credentials: None,
609                }),
610                identity: None,
611                card: None,
612                secure_note: None,
613                ssh_key: None,
614                bank_account: None,
615                drivers_license: None,
616                passport: None,
617                favorite: false,
618                reprompt: CipherRepromptType::None,
619                organization_use_totp: true,
620                edit: true,
621                permissions: None,
622                view_password: true,
623                local_data: None,
624                attachments: None,
625                fields: None,
626                password_history: None,
627                creation_date: "2024-01-01T00:00:00Z".parse().unwrap(),
628                deleted_date: None,
629                revision_date: "2024-01-01T00:00:00Z".parse().unwrap(),
630                archived_date: None,
631                data: None,
632            }
633        };
634
635        repository.set(cipher_id, cipher).await.unwrap();
636    }
637
638    #[tokio::test]
639    async fn test_edit_cipher() {
640        let store: KeyStore<KeySlotIds> = KeyStore::default();
641        {
642            let mut ctx = store.context_mut();
643            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
644            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
645                .unwrap();
646        }
647
648        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
649
650        let api_client = ApiClient::new_mocked(move |mock| {
651            mock.ciphers_api
652                .expect_put()
653                .returning(move |_id, body| {
654                    let body = body.unwrap();
655                    Ok(CipherResponseModel {
656                        object: Some("cipher".to_string()),
657                        id: Some(cipher_id.into()),
658                        name: body.name,
659                        r#type: body.r#type,
660                        organization_id: body
661                            .organization_id
662                            .as_ref()
663                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
664                        folder_id: body
665                            .folder_id
666                            .as_ref()
667                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
668                        favorite: body.favorite,
669                        reprompt: body.reprompt,
670                        key: body.key,
671                        notes: body.notes,
672                        view_password: Some(true),
673                        edit: Some(true),
674                        organization_use_totp: Some(true),
675                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
676                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
677                        deleted_date: None,
678                        login: body.login,
679                        card: body.card,
680                        identity: body.identity,
681                        secure_note: body.secure_note,
682                        ssh_key: body.ssh_key,
683                        bank_account: body.bank_account,
684                        drivers_license: body.drivers_license,
685                        passport: body.passport,
686                        fields: body.fields,
687                        password_history: body.password_history,
688                        attachments: None,
689                        permissions: None,
690                        data: None,
691                        partial_data: None,
692                        archived_date: None,
693                    })
694                })
695                .once();
696        });
697
698        let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
699
700        let repository = MemoryRepository::<Cipher>::default();
701        repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
702        // Update the stored cipher to include a collection_id so we can verify it is preserved.
703        let mut stored = repository.get(cipher_id).await.unwrap().unwrap();
704        stored.collection_ids = vec![collection_id];
705        repository.set(cipher_id, stored).await.unwrap();
706
707        let cipher_view = generate_test_cipher();
708
709        let request = cipher_view.try_into().unwrap();
710
711        let result = edit_cipher(
712            &store,
713            &api_client,
714            &repository,
715            TEST_USER_ID.parse().unwrap(),
716            request,
717            false,
718            false,
719        )
720        .await
721        .unwrap();
722
723        assert_eq!(result.id, Some(cipher_id));
724        assert_eq!(result.name, "Test Login");
725        // collection_ids must be preserved even though CipherResponseModel omits them.
726        assert_eq!(result.collection_ids, vec![collection_id]);
727    }
728
729    /// Fixed org id + key for the PAM-gated fixtures — a partial is always org-owned — with a
730    /// `partial_data` envelope encrypted under that key. Same vectors as the ones pinned in
731    /// `cipher.rs`, so both sides exercise identical ciphertext.
732    const GATED_ORG_UUID: &str = "3cf0d3ba-3ded-4bf3-a51c-b03fd9ac6e07";
733    const GATED_ORG_KEY_B64: &str =
734        "w2LO+nwV4oxwswVYCxlOfRUseXfvU03VzvKQHrqeklPgiMZrspUe6sOBToCnDn9Ay0tuCBn8ykVVRb7PWhub2Q==";
735    const GATED_LOGIN_ENVELOPE: &str = r#"{"name":"2.qip4DSwdOzU2KwY3jgDjUg==|CsGRQgTwAzmszz+dkk5xIg==|rmW/mlnHq2MulR9uNKclD+1UBFLfOimedkq5tPRSLOc=","uris":[{"uri":"2.2na8mpfA1B1OBTUHkDz+fw==|yTWB1nEf3EHIZgsDINM8JnTYyxf7KVZvXraIGAVOiEg=|i2swsODSjEMRaYNnBHAigdphZBBUg2lkPNo763fX12w=","uriChecksum":null,"match":null}]}"#;
736
737    fn gated_key_store() -> (OrganizationId, KeyStore<KeySlotIds>) {
738        let org: OrganizationId = GATED_ORG_UUID.parse().unwrap();
739        let org_key: SymmetricCryptoKey = GATED_ORG_KEY_B64.to_string().try_into().unwrap();
740        let key_store = create_test_crypto_with_user_and_org_key(
741            SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
742            org,
743            org_key,
744        );
745        (org, key_store)
746    }
747
748    /// The copy local state holds for a gated cipher: the reduced envelope and nothing else.
749    fn gated_stored_cipher(cipher_id: CipherId, organization_id: OrganizationId) -> Cipher {
750        Cipher {
751            partial_data: Some(GATED_LOGIN_ENVELOPE.to_string()),
752            id: Some(cipher_id),
753            organization_id: Some(organization_id),
754            folder_id: None,
755            collection_ids: vec![],
756            key: None,
757            name: None,
758            notes: None,
759            r#type: CipherType::Login,
760            login: None,
761            identity: None,
762            card: None,
763            secure_note: None,
764            ssh_key: None,
765            bank_account: None,
766            drivers_license: None,
767            passport: None,
768            favorite: false,
769            reprompt: CipherRepromptType::None,
770            organization_use_totp: false,
771            edit: true,
772            permissions: None,
773            view_password: true,
774            local_data: None,
775            attachments: None,
776            fields: None,
777            password_history: None,
778            creation_date: "2024-01-01T00:00:00Z".parse().unwrap(),
779            deleted_date: None,
780            revision_date: "2024-01-01T00:00:00Z".parse().unwrap(),
781            archived_date: None,
782            data: None,
783        }
784    }
785
786    /// The full original a caller obtains from a lease-authorised read: one prior history entry
787    /// and the password this edit replaces.
788    fn gated_original_view(organization_id: OrganizationId) -> CipherView {
789        let mut view = create_test_login_cipher("old_password");
790        view.organization_id = Some(organization_id);
791        view.password_history = Some(vec![PasswordHistoryView {
792            password: "older_password".to_string(),
793            last_used_date: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
794        }]);
795        view
796    }
797
798    /// The regular edit path builds its original from local state, which for a gated cipher only
799    /// ever holds the partial copy. Editing on that would blank every withheld field and drop the
800    /// item's password history, so it must refuse — before it reaches the server.
801    #[tokio::test]
802    async fn test_edit_refuses_a_gated_stored_cipher() {
803        let (org, store) = gated_key_store();
804        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
805
806        // Deliberately no `expect_put`: the guard has to fire before any request is built.
807        let api_client = ApiClient::new_mocked(|_mock| {});
808
809        let repository = MemoryRepository::<Cipher>::default();
810        repository
811            .set(cipher_id, gated_stored_cipher(cipher_id, org))
812            .await
813            .unwrap();
814
815        let mut cipher_view = generate_test_cipher();
816        cipher_view.organization_id = Some(org);
817
818        let result = edit_cipher(
819            &store,
820            &api_client,
821            &repository,
822            TEST_USER_ID.parse().unwrap(),
823            cipher_view.try_into().unwrap(),
824            false,
825            false,
826        )
827        .await;
828
829        assert!(matches!(result, Err(EditCipherError::GatedCipher)));
830    }
831
832    /// Handing the gated path the partial view the caller already had puts us back in exactly the
833    /// case the regular path refuses, with the blanks supplied by the caller instead of by state.
834    #[tokio::test]
835    async fn test_edit_gated_refuses_a_partial_original() {
836        let (org, store) = gated_key_store();
837        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
838        let api_client = ApiClient::new_mocked(|_mock| {});
839
840        let repository = MemoryRepository::<Cipher>::default();
841        repository
842            .set(cipher_id, gated_stored_cipher(cipher_id, org))
843            .await
844            .unwrap();
845
846        let mut cipher_view = generate_test_cipher();
847        cipher_view.organization_id = Some(org);
848        let mut original = cipher_view.clone();
849        original.partial = true;
850
851        let result = edit_gated_cipher(
852            &store,
853            &api_client,
854            &repository,
855            TEST_USER_ID.parse().unwrap(),
856            cipher_view.try_into().unwrap(),
857            original,
858            false,
859            false,
860        )
861        .await;
862
863        assert!(matches!(result, Err(EditCipherError::PartialOriginal)));
864    }
865
866    /// The whole reason this path exists: password history is diffed against the caller's full
867    /// original, not against the partial copy in state. The partial has no history at all, so a
868    /// wrong original shows up as a short array in the request body.
869    #[tokio::test]
870    async fn test_edit_gated_carries_history_from_the_supplied_original() {
871        let (org, store) = gated_key_store();
872        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
873
874        let api_client = ApiClient::new_mocked(move |mock| {
875            mock.ciphers_api
876                .expect_put()
877                .returning(move |_id, body| {
878                    let body = body.unwrap();
879                    // The original's one prior entry, plus the entry for the password this edit
880                    // replaces. Building on the partial copy would have sent none of either.
881                    assert_eq!(body.password_history.as_ref().map(|h| h.len()), Some(2));
882                    Ok(gated_response_model(cipher_id, org, body.r#type))
883                })
884                .once();
885        });
886
887        let repository = MemoryRepository::<Cipher>::default();
888        repository
889            .set(cipher_id, gated_stored_cipher(cipher_id, org))
890            .await
891            .unwrap();
892
893        let mut cipher_view = create_test_login_cipher("new_password");
894        cipher_view.organization_id = Some(org);
895
896        let result = edit_gated_cipher(
897            &store,
898            &api_client,
899            &repository,
900            TEST_USER_ID.parse().unwrap(),
901            cipher_view.try_into().unwrap(),
902            gated_original_view(org),
903            false,
904            false,
905        )
906        .await
907        .unwrap();
908
909        // The server withholds secrets from a gated write-return, so what comes back — and what
910        // is persisted — is still the reduced shape.
911        assert!(result.partial);
912        let stored = repository.get(cipher_id).await.unwrap().unwrap();
913        assert!(stored.partial_data.is_some());
914    }
915
916    /// Defence in depth for the durable-state property: the server strips a gated write-return, so
917    /// a full one should be unreachable — but were it ever to arrive, persisting it would put
918    /// lease-scoped secrets on disk, outliving the lease that justified them.
919    #[tokio::test]
920    async fn test_edit_gated_does_not_persist_a_full_write_return() {
921        let (org, store) = gated_key_store();
922        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
923
924        let api_client = ApiClient::new_mocked(move |mock| {
925            mock.ciphers_api
926                .expect_put()
927                .returning(move |_id, body| {
928                    let body = body.unwrap();
929                    // A server that has not been taught to strip write-returns.
930                    Ok(CipherResponseModel {
931                        partial_data: None,
932                        name: body.name,
933                        login: body.login,
934                        ..gated_response_model(cipher_id, org, body.r#type)
935                    })
936                })
937                .once();
938        });
939
940        let repository = MemoryRepository::<Cipher>::default();
941        repository
942            .set(cipher_id, gated_stored_cipher(cipher_id, org))
943            .await
944            .unwrap();
945
946        let mut cipher_view = create_test_login_cipher("new_password");
947        cipher_view.organization_id = Some(org);
948
949        let result = edit_gated_cipher(
950            &store,
951            &api_client,
952            &repository,
953            TEST_USER_ID.parse().unwrap(),
954            cipher_view.try_into().unwrap(),
955            gated_original_view(org),
956            false,
957            false,
958        )
959        .await
960        .unwrap();
961
962        // The caller still gets the full view in memory — it is the write to disk that is refused.
963        assert!(!result.partial);
964        let stored = repository.get(cipher_id).await.unwrap().unwrap();
965        assert!(stored.partial_data.is_some(), "local state must stay gated");
966        assert!(
967            stored.login.is_none(),
968            "no secret field may reach local state"
969        );
970    }
971
972    /// A gated write-return: metadata and the reduced envelope, every secret field withheld.
973    fn gated_response_model(
974        cipher_id: CipherId,
975        organization_id: OrganizationId,
976        r#type: Option<bitwarden_api_api::models::CipherType>,
977    ) -> CipherResponseModel {
978        CipherResponseModel {
979            object: Some("cipher".to_string()),
980            id: Some(cipher_id.into()),
981            organization_id: Some(organization_id.into()),
982            r#type,
983            partial_data: Some(GATED_LOGIN_ENVELOPE.to_string()),
984            name: None,
985            notes: None,
986            login: None,
987            card: None,
988            identity: None,
989            secure_note: None,
990            ssh_key: None,
991            bank_account: None,
992            drivers_license: None,
993            passport: None,
994            fields: None,
995            password_history: None,
996            attachments: None,
997            permissions: None,
998            data: None,
999            folder_id: None,
1000            favorite: Some(false),
1001            reprompt: None,
1002            key: None,
1003            view_password: Some(true),
1004            edit: Some(true),
1005            organization_use_totp: Some(false),
1006            revision_date: Some("2025-01-01T00:00:00Z".to_string()),
1007            creation_date: Some("2025-01-01T00:00:00Z".to_string()),
1008            deleted_date: None,
1009            archived_date: None,
1010        }
1011    }
1012
1013    #[tokio::test]
1014    async fn test_edit_partial_cipher() {
1015        let store: KeyStore<KeySlotIds> = KeyStore::default();
1016        {
1017            let mut ctx = store.context_mut();
1018            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
1019            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
1020                .unwrap();
1021        }
1022
1023        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
1024        let new_folder_id: FolderId = "9b1e7c8f-3a04-4d2e-9d1e-b18100abcdef".parse().unwrap();
1025
1026        let api_client = ApiClient::new_mocked(move |mock| {
1027            mock.ciphers_api
1028                .expect_put_partial()
1029                .returning(move |id, body| {
1030                    let body = body.unwrap();
1031                    let expected_id: uuid::Uuid = cipher_id.into();
1032                    assert_eq!(id, expected_id);
1033                    assert_eq!(body.favorite, Some(true));
1034                    assert_eq!(
1035                        body.folder_id.as_deref(),
1036                        Some(new_folder_id.to_string().as_str())
1037                    );
1038                    Ok(CipherResponseModel {
1039                        object: Some("cipher".to_string()),
1040                        id: Some(cipher_id.into()),
1041                        name: Some(
1042                            "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".to_string(),
1043                        ),
1044                        r#type: Some(bitwarden_api_api::models::CipherType::Login),
1045                        organization_id: None,
1046                        folder_id: Some(new_folder_id.into()),
1047                        favorite: Some(true),
1048                        reprompt: Some(bitwarden_api_api::models::CipherRepromptType::None),
1049                        key: None,
1050                        notes: None,
1051                        view_password: Some(true),
1052                        edit: Some(false),
1053                        organization_use_totp: Some(true),
1054                        revision_date: Some("2025-01-02T00:00:00Z".to_string()),
1055                        creation_date: Some("2024-01-01T00:00:00Z".to_string()),
1056                        deleted_date: None,
1057                        login: None,
1058                        card: None,
1059                        identity: None,
1060                        secure_note: None,
1061                        ssh_key: None,
1062                        bank_account: None,
1063                        drivers_license: None,
1064                        passport: None,
1065                        fields: None,
1066                        password_history: None,
1067                        attachments: None,
1068                        permissions: None,
1069                        data: None,
1070                        partial_data: None,
1071                        archived_date: None,
1072                    })
1073                })
1074                .once();
1075        });
1076
1077        let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
1078
1079        let repository = MemoryRepository::<Cipher>::default();
1080        repository_add_cipher(&repository, &store, cipher_id, "stored_name").await;
1081        // Stamp a collection id to verify it is preserved across partial edit.
1082        let mut stored = repository.get(cipher_id).await.unwrap().unwrap();
1083        stored.collection_ids = vec![collection_id];
1084        repository.set(cipher_id, stored).await.unwrap();
1085
1086        let request = CipherPartialEditRequest {
1087            id: cipher_id,
1088            folder_id: Some(new_folder_id),
1089            favorite: true,
1090        };
1091
1092        let result = partial_edit_cipher(&store, &api_client, &repository, request, false)
1093            .await
1094            .unwrap();
1095
1096        assert_eq!(result.id, Some(cipher_id));
1097        assert_eq!(result.folder_id, Some(new_folder_id));
1098        assert!(result.favorite);
1099        // Partial endpoint omits collection_ids; they must be preserved from the original.
1100        assert_eq!(result.collection_ids, vec![collection_id]);
1101    }
1102
1103    #[tokio::test]
1104    async fn test_edit_partial_cipher_does_not_exist() {
1105        let store: KeyStore<KeySlotIds> = KeyStore::default();
1106
1107        let repository = MemoryRepository::<Cipher>::default();
1108        let api_client = ApiClient::new_mocked(|_| {});
1109
1110        let request = CipherPartialEditRequest {
1111            id: TEST_CIPHER_ID.parse().unwrap(),
1112            folder_id: None,
1113            favorite: false,
1114        };
1115
1116        let result = partial_edit_cipher(&store, &api_client, &repository, request, false).await;
1117
1118        assert!(matches!(
1119            result.unwrap_err(),
1120            EditCipherError::ItemNotFound(_)
1121        ));
1122    }
1123
1124    #[tokio::test]
1125    async fn test_edit_cipher_does_not_exist() {
1126        let store: KeyStore<KeySlotIds> = KeyStore::default();
1127
1128        let repository = MemoryRepository::<Cipher>::default();
1129
1130        let cipher_view = generate_test_cipher();
1131        let api_client = ApiClient::new_mocked(|_| {});
1132
1133        let request = cipher_view.try_into().unwrap();
1134
1135        let result = edit_cipher(
1136            &store,
1137            &api_client,
1138            &repository,
1139            TEST_USER_ID.parse().unwrap(),
1140            request,
1141            false,
1142            false,
1143        )
1144        .await;
1145
1146        assert!(result.is_err());
1147        assert!(matches!(
1148            result.unwrap_err(),
1149            EditCipherError::ItemNotFound(_)
1150        ));
1151    }
1152
1153    #[tokio::test]
1154    async fn test_edit_cipher_http_error() {
1155        let store: KeyStore<KeySlotIds> = KeyStore::default();
1156        {
1157            let mut ctx = store.context_mut();
1158            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
1159            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
1160                .unwrap();
1161        }
1162
1163        let cipher_id: CipherId = "5faa9684-c793-4a2d-8a12-b33900187097".parse().unwrap();
1164
1165        let api_client = ApiClient::new_mocked(move |mock| {
1166            mock.ciphers_api
1167                .expect_put()
1168                .returning(move |_id, _body| Err(std::io::Error::other("Simulated error").into()));
1169        });
1170
1171        let repository = MemoryRepository::<Cipher>::default();
1172        repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
1173        let cipher_view = generate_test_cipher();
1174
1175        let request = cipher_view.try_into().unwrap();
1176
1177        let result = edit_cipher(
1178            &store,
1179            &api_client,
1180            &repository,
1181            TEST_USER_ID.parse().unwrap(),
1182            request,
1183            false,
1184            false,
1185        )
1186        .await;
1187
1188        assert!(result.is_err());
1189        assert!(matches!(result.unwrap_err(), EditCipherError::Api(_)));
1190    }
1191
1192    /// Build the edit-side view the way the flow does: request → view, then
1193    /// fold in password history against the decrypted original.
1194    fn edit_view_with_history(new_cipher: CipherView, original: &CipherView) -> CipherView {
1195        let mut view: CipherView =
1196            convert_request_to_cipher_view(CipherEditRequest::try_from(new_cipher).unwrap());
1197        view.update_password_history(original);
1198        view
1199    }
1200
1201    #[test]
1202    fn test_password_history_on_password_change() {
1203        let original_cipher = create_test_login_cipher("old_password");
1204
1205        let start = Utc::now();
1206        let view =
1207            edit_view_with_history(create_test_login_cipher("new_password"), &original_cipher);
1208        let end = Utc::now();
1209        let history = view.password_history.unwrap_or_default();
1210
1211        assert_eq!(history.len(), 1);
1212        assert!(
1213            history[0].last_used_date >= start && history[0].last_used_date <= end,
1214            "last_used_date was not set properly"
1215        );
1216        assert_eq!(history[0].password, "old_password");
1217    }
1218
1219    #[test]
1220    fn test_password_history_on_unchanged_password() {
1221        let original_cipher = create_test_login_cipher("same_password");
1222        let view =
1223            edit_view_with_history(create_test_login_cipher("same_password"), &original_cipher);
1224
1225        assert!(view.password_history.unwrap_or_default().is_empty());
1226    }
1227
1228    #[test]
1229    fn test_password_history_is_preserved() {
1230        let mut original_cipher = create_test_login_cipher("same_password");
1231        original_cipher.password_history = Some(
1232            (0..4)
1233                .map(|i| PasswordHistoryView {
1234                    password: format!("old_password_{}", i),
1235                    last_used_date: Utc.with_ymd_and_hms(2025, i + 1, i + 1, i, i, i).unwrap(),
1236                })
1237                .collect(),
1238        );
1239
1240        let view =
1241            edit_view_with_history(create_test_login_cipher("same_password"), &original_cipher);
1242        let history = view.password_history.unwrap_or_default();
1243
1244        assert_eq!(history[0].password, "old_password_0");
1245
1246        assert_eq!(
1247            history[0].last_used_date,
1248            Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
1249        );
1250        assert_eq!(history[1].password, "old_password_1");
1251        assert_eq!(
1252            history[1].last_used_date,
1253            Utc.with_ymd_and_hms(2025, 2, 2, 1, 1, 1).unwrap()
1254        );
1255        assert_eq!(history[2].password, "old_password_2");
1256        assert_eq!(
1257            history[2].last_used_date,
1258            Utc.with_ymd_and_hms(2025, 3, 3, 2, 2, 2).unwrap()
1259        );
1260        assert_eq!(history[3].password, "old_password_3");
1261        assert_eq!(
1262            history[3].last_used_date,
1263            Utc.with_ymd_and_hms(2025, 4, 4, 3, 3, 3).unwrap()
1264        );
1265    }
1266
1267    #[test]
1268    fn test_password_history_with_hidden_fields() {
1269        let mut original_cipher = create_test_login_cipher("password");
1270        original_cipher.fields = Some(vec![FieldView {
1271            name: Some("Secret Key".to_string()),
1272            value: Some("old_secret_value".to_string()),
1273            r#type: FieldType::Hidden,
1274            linked_id: None,
1275        }]);
1276
1277        let mut new_cipher = create_test_login_cipher("password");
1278        new_cipher.fields = Some(vec![FieldView {
1279            name: Some("Secret Key".to_string()),
1280            value: Some("new_secret_value".to_string()),
1281            r#type: FieldType::Hidden,
1282            linked_id: None,
1283        }]);
1284
1285        let view = edit_view_with_history(new_cipher, &original_cipher);
1286        let history = view.password_history.unwrap_or_default();
1287
1288        assert_eq!(history.len(), 1);
1289        assert_eq!(history[0].password, "Secret Key: old_secret_value");
1290    }
1291
1292    #[test]
1293    fn test_password_history_length_limit() {
1294        let mut original_cipher = create_test_login_cipher("password");
1295        original_cipher.password_history = Some(
1296            (0..10)
1297                .map(|i| PasswordHistoryView {
1298                    password: format!("old_password_{}", i),
1299                    last_used_date: Utc::now(),
1300                })
1301                .collect(),
1302        );
1303
1304        let view =
1305            edit_view_with_history(create_test_login_cipher("new_password"), &original_cipher);
1306        let history = view.password_history.unwrap_or_default();
1307
1308        assert_eq!(history.len(), MAX_PASSWORD_HISTORY_ENTRIES);
1309        // Most recent change (original password) should be first
1310        assert_eq!(history[0].password, "password");
1311
1312        assert_eq!(history[1].password, "old_password_0");
1313        assert_eq!(history[2].password, "old_password_1");
1314        assert_eq!(history[3].password, "old_password_2");
1315        assert_eq!(history[4].password, "old_password_3");
1316    }
1317
1318    mod blob_encrypt {
1319        use bitwarden_core::key_management::create_test_crypto_with_user_key;
1320        use bitwarden_crypto::SymmetricCryptoKey;
1321
1322        use super::*;
1323        use crate::cipher::blob::try_parse_blob;
1324
1325        /// `EncryptMode::Blob(CipherView)` clears `password_history` from the
1326        /// wire-shaped `Cipher` — history must travel inside the sealed blob,
1327        /// not as a top-level encrypted field.
1328        #[test]
1329        fn password_history_lives_inside_blob_not_on_wire() {
1330            let store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
1331                SymmetricKeyAlgorithm::Aes256CbcHmac,
1332            ));
1333
1334            let original = create_test_login_cipher("old_password");
1335            let mut view = create_test_login_cipher("new_password");
1336            view.update_password_history(&original);
1337            // Sanity: the in-flight view captured the old password.
1338            assert_eq!(view.password_history.as_ref().unwrap().len(), 1);
1339
1340            let cipher: Cipher = store.encrypt(EncryptMode::Blob(view)).unwrap();
1341
1342            assert!(try_parse_blob(&cipher).is_some());
1343            assert!(
1344                cipher.password_history.is_none(),
1345                "password history must live inside the blob, not on the wire",
1346            );
1347            assert!(cipher.login.is_none());
1348            assert!(cipher.notes.is_none());
1349        }
1350
1351        /// End-to-end: a password change picked up by `update_password_history`
1352        /// is sealed inside the blob and unsealed back out by
1353        /// `BlobAwareDecrypt`.
1354        #[test]
1355        fn password_history_round_trips_through_the_blob() {
1356            let store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
1357                SymmetricKeyAlgorithm::Aes256CbcHmac,
1358            ));
1359
1360            let original = create_test_login_cipher("old_password");
1361            let mut view = create_test_login_cipher("new_password");
1362            view.update_password_history(&original);
1363
1364            let cipher: Cipher = store.encrypt(EncryptMode::Blob(view)).unwrap();
1365            let restored: CipherView = store.decrypt(&cipher).unwrap();
1366
1367            let history = restored
1368                .password_history
1369                .expect("history should round-trip through the blob");
1370            assert_eq!(history.len(), 1);
1371            assert_eq!(history[0].password, "old_password");
1372        }
1373    }
1374}