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