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, EncString, IdentifyKey, KeyStore};
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}
49
50impl<T> From<bitwarden_api_api::apis::Error<T>> for EditCipherError {
51    fn from(val: bitwarden_api_api::apis::Error<T>) -> Self {
52        Self::Api(val.into())
53    }
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#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
61pub struct CipherEditRequest {
62    pub id: CipherId,
63
64    pub organization_id: Option<OrganizationId>,
65    pub folder_id: Option<FolderId>,
66    pub favorite: bool,
67    pub reprompt: CipherRepromptType,
68    pub name: String,
69    pub notes: Option<String>,
70    pub fields: Vec<FieldView>,
71    pub r#type: CipherViewType,
72    pub revision_date: DateTime<Utc>,
73    pub archived_date: Option<DateTime<Utc>>,
74    pub attachments: Vec<AttachmentView>,
75    pub key: Option<EncString>,
76}
77
78impl TryFrom<CipherView> for CipherEditRequest {
79    type Error = MissingFieldError;
80
81    fn try_from(value: CipherView) -> Result<Self, Self::Error> {
82        let type_data = match value.r#type {
83            CipherType::Login => value.login.map(CipherViewType::Login),
84            CipherType::SecureNote => value.secure_note.map(CipherViewType::SecureNote),
85            CipherType::Card => value.card.map(CipherViewType::Card),
86            CipherType::Identity => value.identity.map(CipherViewType::Identity),
87            CipherType::SshKey => value.ssh_key.map(CipherViewType::SshKey),
88            CipherType::BankAccount => value.bank_account.map(CipherViewType::BankAccount),
89            CipherType::DriversLicense => value.drivers_license.map(CipherViewType::DriversLicense),
90            CipherType::Passport => value.passport.map(CipherViewType::Passport),
91        };
92        Ok(Self {
93            id: value.id.ok_or(MissingFieldError("id"))?,
94            organization_id: value.organization_id,
95            folder_id: value.folder_id,
96            favorite: value.favorite,
97            reprompt: value.reprompt,
98            key: value.key,
99            name: value.name,
100            notes: value.notes,
101            fields: value.fields.unwrap_or_default(),
102            r#type: require!(type_data),
103            attachments: value.attachments.unwrap_or_default(),
104            revision_date: value.revision_date,
105            archived_date: value.archived_date,
106        })
107    }
108}
109
110/// Request to update the subset of cipher fields that a user without edit
111/// permissions is still allowed to change (`folder_id` and `favorite`).
112///
113/// Backed by the `PUT /ciphers/{id}/partial` server endpoint, which authorizes
114/// based on view (not edit) access.
115#[derive(Clone, Serialize, Deserialize, Debug)]
116#[serde(rename_all = "camelCase")]
117#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
118#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
119pub struct CipherPartialEditRequest {
120    pub id: CipherId,
121    pub folder_id: Option<FolderId>,
122    pub favorite: bool,
123}
124
125/// Internal helper to convert a [`CipherEditRequest`] into a [`CipherView`]
126/// so the existing `CipherView` encryption pipeline can be reused.
127///
128/// This conversion is lossy and intended for use only within the edit flow,
129/// as the `CipherView` produced will not have all fields populated (e.g. `collection_ids`).
130pub(crate) fn convert_request_to_cipher_view(r: CipherEditRequest) -> CipherView {
131    CipherView {
132        id: Some(r.id),
133        organization_id: r.organization_id,
134        folder_id: r.folder_id,
135        // `collection_ids` is empty because collections are updated via a separate endpoint.
136        collection_ids: vec![],
137        key: r.key,
138        name: r.name,
139        notes: r.notes,
140        r#type: r.r#type.get_cipher_type(),
141        login: r.r#type.as_login_view().cloned(),
142        identity: r.r#type.as_identity_view().cloned(),
143        card: r.r#type.as_card_view().cloned(),
144        secure_note: r.r#type.as_secure_note_view().cloned(),
145        ssh_key: r.r#type.as_ssh_key_view().cloned(),
146        bank_account: r.r#type.as_bank_account_view().cloned(),
147        drivers_license: r.r#type.as_drivers_license_view().cloned(),
148        passport: r.r#type.as_passport_view().cloned(),
149        favorite: r.favorite,
150        reprompt: r.reprompt,
151        organization_use_totp: false,
152        edit: true,
153        permissions: None,
154        view_password: true,
155        local_data: None,
156        attachments: Some(r.attachments),
157        attachment_decryption_failures: None,
158        fields: Some(r.fields),
159        password_history: None,
160        // `creation_date` is overwritten by the server on merge
161        creation_date: Utc::now(),
162        deleted_date: None,
163        revision_date: r.revision_date,
164        archived_date: r.archived_date,
165    }
166}
167
168// `use_strict_decryption`, `enable_cipher_key_encryption`, and `use_blob` are
169// short-lived feature-rollout flags that will be removed once their migrations
170// complete, at which point the argument count drops back under the limit.
171#[allow(clippy::too_many_arguments)]
172async fn edit_cipher<R: Repository<Cipher> + ?Sized>(
173    key_store: &KeyStore<KeySlotIds>,
174    api_client: &bitwarden_api_api::apis::ApiClient,
175    repository: &R,
176    encrypted_for: UserId,
177    request: CipherEditRequest,
178    use_strict_decryption: bool,
179    enable_cipher_key_encryption: bool,
180    use_blob: bool,
181) -> Result<CipherView, EditCipherError> {
182    let cipher_id = request.id;
183
184    let original_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
185    let original_cipher_view: CipherView = if use_strict_decryption {
186        key_store.decrypt(&StrictDecrypt(original_cipher.clone()))?
187    } else {
188        key_store.decrypt(&original_cipher)?
189    };
190
191    let mut view: CipherView = convert_request_to_cipher_view(request);
192    view.update_password_history(&original_cipher_view);
193
194    // TODO: Once this flag is removed, the key generation logic should be
195    // moved directly into the CompositeEncryptable implementation.
196    if view.key.is_none() && enable_cipher_key_encryption {
197        let key = view.key_identifier();
198        view.generate_cipher_key(&mut key_store.context(), key)?;
199    }
200
201    let mode = if use_blob {
202        EncryptMode::Blob(view)
203    } else {
204        EncryptMode::Legacy(view)
205    };
206
207    let cipher: Cipher = key_store.encrypt(mode)?;
208    let mut cipher_request: CipherRequestModel = cipher.try_into()?;
209    cipher_request.encrypted_for = Some(encrypted_for.into());
210
211    let cipher: Cipher = api_client
212        .ciphers_api()
213        .put(cipher_id.into(), Some(cipher_request))
214        .await
215        .map_err(ApiError::from)?
216        .merge_with_cipher(Some(original_cipher))?;
217    debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
218    repository.set(cipher_id, cipher.clone()).await?;
219
220    Ok(if use_strict_decryption {
221        key_store.decrypt(&StrictDecrypt(cipher))?
222    } else {
223        key_store.decrypt(&cipher)?
224    })
225}
226
227/// Update only the cipher fields available to users without edit permissions
228/// (`folder_id` and `favorite`) via the server's partial-update endpoint.
229async fn partial_edit_cipher<R: Repository<Cipher> + ?Sized>(
230    key_store: &KeyStore<KeySlotIds>,
231    api_client: &bitwarden_api_api::apis::ApiClient,
232    repository: &R,
233    request: CipherPartialEditRequest,
234    use_strict_decryption: bool,
235) -> Result<CipherView, EditCipherError> {
236    let cipher_id = request.id;
237
238    let original_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
239
240    let partial_request = CipherPartialRequestModel {
241        folder_id: request.folder_id.map(|id| id.to_string()),
242        favorite: Some(request.favorite),
243    };
244
245    let cipher: Cipher = api_client
246        .ciphers_api()
247        .put_partial(cipher_id.into(), Some(partial_request))
248        .await
249        .map_err(ApiError::from)?
250        .merge_with_cipher(Some(original_cipher))?;
251    debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
252    repository.set(cipher_id, cipher.clone()).await?;
253
254    Ok(if use_strict_decryption {
255        key_store.decrypt(&StrictDecrypt(cipher))?
256    } else {
257        key_store.decrypt(&cipher)?
258    })
259}
260
261#[allow(deprecated)]
262#[cfg_attr(feature = "wasm", wasm_bindgen)]
263impl CiphersClient {
264    /// Edit an existing [Cipher] and save it to the server.
265    pub async fn edit(&self, request: CipherEditRequest) -> Result<CipherView, EditCipherError> {
266        let key_store = self.client.internal.get_key_store();
267        let config = self.client.internal.get_api_configurations();
268        let repository = self.get_repository()?;
269
270        let user_id = self
271            .client
272            .internal
273            .get_user_id()
274            .ok_or(NotAuthenticatedError)?;
275
276        let enable_cipher_key_encryption =
277            self.client.flags().get().await.enable_cipher_key_encryption;
278
279        let use_blob = self.should_use_blob_encryption(request.organization_id);
280
281        edit_cipher(
282            key_store,
283            &config.api_client,
284            repository.as_ref(),
285            user_id,
286            request,
287            self.is_strict_decrypt().await,
288            enable_cipher_key_encryption,
289            use_blob,
290        )
291        .await
292    }
293
294    /// Update only `folder_id` and `favorite` on an existing [Cipher].
295    ///
296    /// Intended for users who do not have edit permissions on the cipher, but
297    /// are still allowed to change these personal organization fields.
298    pub async fn edit_partial(
299        &self,
300        request: CipherPartialEditRequest,
301    ) -> Result<CipherView, EditCipherError> {
302        let key_store = self.client.internal.get_key_store();
303        let config = self.client.internal.get_api_configurations();
304        let repository = self.get_repository()?;
305
306        partial_edit_cipher(
307            key_store,
308            &config.api_client,
309            repository.as_ref(),
310            request,
311            self.is_strict_decrypt().await,
312        )
313        .await
314    }
315
316    /// Adds the cipher matched by [CipherId] to any number of collections on the server.
317    pub async fn update_collection(
318        &self,
319        cipher_id: CipherId,
320        collection_ids: Vec<CollectionId>,
321        is_admin: bool,
322    ) -> Result<CipherView, EditCipherError> {
323        let req = CipherCollectionsRequestModel {
324            collection_ids: collection_ids
325                .into_iter()
326                .map(|id| id.to_string())
327                .collect(),
328        };
329        let repository = self.get_repository()?;
330
331        let api_config = self.client.internal.get_api_configurations();
332        let api = api_config.api_client.ciphers_api();
333        let orig_cipher = repository.get(cipher_id).await?;
334        let cipher = if is_admin {
335            api.put_collections_admin(&cipher_id.to_string(), Some(req))
336                .await?
337                .merge_with_cipher(orig_cipher)?
338        } else {
339            let cipher_response = api
340                .put_collections_v_next(cipher_id.into(), Some(req))
341                .await?
342                .cipher
343                .map(|c| *c)
344                .ok_or(MissingFieldError("cipher"))?;
345            let response: Cipher = cipher_response.merge_with_cipher(orig_cipher)?;
346            repository.set(cipher_id, response.clone()).await?;
347            response
348        };
349
350        Ok(self
351            .decrypt(cipher)
352            .await
353            .map_err(|_| CryptoError::KeyDecrypt)?)
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel};
360    use bitwarden_core::key_management::SymmetricKeySlotId;
361    use bitwarden_crypto::{KeyStore, PrimitiveEncryptable, SymmetricKeyAlgorithm};
362    use bitwarden_test::MemoryRepository;
363    use chrono::TimeZone;
364
365    use super::*;
366    use crate::{
367        Cipher, CipherId, CipherRepromptType, CipherType, FieldType, Login, LoginView,
368        PasswordHistoryView, password_history::MAX_PASSWORD_HISTORY_ENTRIES,
369    };
370
371    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
372    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
373
374    fn generate_test_cipher() -> CipherView {
375        CipherView {
376            id: Some(TEST_CIPHER_ID.parse().unwrap()),
377            organization_id: None,
378            folder_id: None,
379            collection_ids: vec![],
380            key: None,
381            name: "Test Login".to_string(),
382            notes: None,
383            r#type: CipherType::Login,
384            login: Some(LoginView {
385                username: Some("[email protected]".to_string()),
386                password: Some("password123".to_string()),
387                password_revision_date: None,
388                uris: None,
389                totp: None,
390                autofill_on_page_load: None,
391                fido2_credentials: None,
392            }),
393            identity: None,
394            card: None,
395            secure_note: None,
396            ssh_key: None,
397            bank_account: None,
398            passport: None,
399            drivers_license: None,
400            favorite: false,
401            reprompt: CipherRepromptType::None,
402            organization_use_totp: true,
403            edit: true,
404            permissions: None,
405            view_password: true,
406            local_data: None,
407            attachments: None,
408            attachment_decryption_failures: None,
409            fields: None,
410            password_history: None,
411            creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
412            deleted_date: None,
413            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
414            archived_date: None,
415        }
416    }
417
418    fn create_test_login_cipher(password: &str) -> CipherView {
419        let mut cipher_view = generate_test_cipher();
420        if let Some(ref mut login) = cipher_view.login {
421            login.password = Some(password.to_string());
422        }
423        cipher_view
424    }
425
426    async fn repository_add_cipher(
427        repository: &MemoryRepository<Cipher>,
428        store: &KeyStore<KeySlotIds>,
429        cipher_id: CipherId,
430        name: &str,
431    ) {
432        let cipher = {
433            let mut ctx = store.context();
434
435            Cipher {
436                id: Some(cipher_id),
437                organization_id: None,
438                folder_id: None,
439                collection_ids: vec![],
440                key: None,
441                name: Some(name.encrypt(&mut ctx, SymmetricKeySlotId::User).unwrap()),
442                notes: None,
443                r#type: CipherType::Login,
444                login: Some(Login {
445                    username: Some("[email protected]")
446                        .map(|u| u.encrypt(&mut ctx, SymmetricKeySlotId::User))
447                        .transpose()
448                        .unwrap(),
449                    password: Some("password123")
450                        .map(|p| p.encrypt(&mut ctx, SymmetricKeySlotId::User))
451                        .transpose()
452                        .unwrap(),
453                    password_revision_date: None,
454                    uris: None,
455                    totp: None,
456                    autofill_on_page_load: None,
457                    fido2_credentials: None,
458                }),
459                identity: None,
460                card: None,
461                secure_note: None,
462                ssh_key: None,
463                bank_account: None,
464                drivers_license: None,
465                passport: None,
466                favorite: false,
467                reprompt: CipherRepromptType::None,
468                organization_use_totp: true,
469                edit: true,
470                permissions: None,
471                view_password: true,
472                local_data: None,
473                attachments: None,
474                fields: None,
475                password_history: None,
476                creation_date: "2024-01-01T00:00:00Z".parse().unwrap(),
477                deleted_date: None,
478                revision_date: "2024-01-01T00:00:00Z".parse().unwrap(),
479                archived_date: None,
480                data: None,
481            }
482        };
483
484        repository.set(cipher_id, cipher).await.unwrap();
485    }
486
487    #[tokio::test]
488    async fn test_edit_cipher() {
489        let store: KeyStore<KeySlotIds> = KeyStore::default();
490        {
491            let mut ctx = store.context_mut();
492            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
493            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
494                .unwrap();
495        }
496
497        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
498
499        let api_client = ApiClient::new_mocked(move |mock| {
500            mock.ciphers_api
501                .expect_put()
502                .returning(move |_id, body| {
503                    let body = body.unwrap();
504                    Ok(CipherResponseModel {
505                        object: Some("cipher".to_string()),
506                        id: Some(cipher_id.into()),
507                        name: Some(body.name),
508                        r#type: body.r#type,
509                        organization_id: body
510                            .organization_id
511                            .as_ref()
512                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
513                        folder_id: body
514                            .folder_id
515                            .as_ref()
516                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
517                        favorite: body.favorite,
518                        reprompt: body.reprompt,
519                        key: body.key,
520                        notes: body.notes,
521                        view_password: Some(true),
522                        edit: Some(true),
523                        organization_use_totp: Some(true),
524                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
525                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
526                        deleted_date: None,
527                        login: body.login,
528                        card: body.card,
529                        identity: body.identity,
530                        secure_note: body.secure_note,
531                        ssh_key: body.ssh_key,
532                        bank_account: body.bank_account,
533                        drivers_license: body.drivers_license,
534                        passport: body.passport,
535                        fields: body.fields,
536                        password_history: body.password_history,
537                        attachments: None,
538                        permissions: None,
539                        data: None,
540                        archived_date: None,
541                    })
542                })
543                .once();
544        });
545
546        let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
547
548        let repository = MemoryRepository::<Cipher>::default();
549        repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
550        // Update the stored cipher to include a collection_id so we can verify it is preserved.
551        let mut stored = repository.get(cipher_id).await.unwrap().unwrap();
552        stored.collection_ids = vec![collection_id];
553        repository.set(cipher_id, stored).await.unwrap();
554
555        let cipher_view = generate_test_cipher();
556
557        let request = cipher_view.try_into().unwrap();
558
559        let result = edit_cipher(
560            &store,
561            &api_client,
562            &repository,
563            TEST_USER_ID.parse().unwrap(),
564            request,
565            false,
566            false,
567            false,
568        )
569        .await
570        .unwrap();
571
572        assert_eq!(result.id, Some(cipher_id));
573        assert_eq!(result.name, "Test Login");
574        // collection_ids must be preserved even though CipherResponseModel omits them.
575        assert_eq!(result.collection_ids, vec![collection_id]);
576    }
577
578    #[tokio::test]
579    async fn test_edit_partial_cipher() {
580        let store: KeyStore<KeySlotIds> = KeyStore::default();
581        {
582            let mut ctx = store.context_mut();
583            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
584            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
585                .unwrap();
586        }
587
588        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
589        let new_folder_id: FolderId = "9b1e7c8f-3a04-4d2e-9d1e-b18100abcdef".parse().unwrap();
590
591        let api_client = ApiClient::new_mocked(move |mock| {
592            mock.ciphers_api
593                .expect_put_partial()
594                .returning(move |id, body| {
595                    let body = body.unwrap();
596                    let expected_id: uuid::Uuid = cipher_id.into();
597                    assert_eq!(id, expected_id);
598                    assert_eq!(body.favorite, Some(true));
599                    assert_eq!(
600                        body.folder_id.as_deref(),
601                        Some(new_folder_id.to_string().as_str())
602                    );
603                    Ok(CipherResponseModel {
604                        object: Some("cipher".to_string()),
605                        id: Some(cipher_id.into()),
606                        name: Some(
607                            "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".to_string(),
608                        ),
609                        r#type: Some(bitwarden_api_api::models::CipherType::Login),
610                        organization_id: None,
611                        folder_id: Some(new_folder_id.into()),
612                        favorite: Some(true),
613                        reprompt: Some(bitwarden_api_api::models::CipherRepromptType::None),
614                        key: None,
615                        notes: None,
616                        view_password: Some(true),
617                        edit: Some(false),
618                        organization_use_totp: Some(true),
619                        revision_date: Some("2025-01-02T00:00:00Z".to_string()),
620                        creation_date: Some("2024-01-01T00:00:00Z".to_string()),
621                        deleted_date: None,
622                        login: None,
623                        card: None,
624                        identity: None,
625                        secure_note: None,
626                        ssh_key: None,
627                        bank_account: None,
628                        drivers_license: None,
629                        passport: None,
630                        fields: None,
631                        password_history: None,
632                        attachments: None,
633                        permissions: None,
634                        data: None,
635                        archived_date: None,
636                    })
637                })
638                .once();
639        });
640
641        let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
642
643        let repository = MemoryRepository::<Cipher>::default();
644        repository_add_cipher(&repository, &store, cipher_id, "stored_name").await;
645        // Stamp a collection id to verify it is preserved across partial edit.
646        let mut stored = repository.get(cipher_id).await.unwrap().unwrap();
647        stored.collection_ids = vec![collection_id];
648        repository.set(cipher_id, stored).await.unwrap();
649
650        let request = CipherPartialEditRequest {
651            id: cipher_id,
652            folder_id: Some(new_folder_id),
653            favorite: true,
654        };
655
656        let result = partial_edit_cipher(&store, &api_client, &repository, request, false)
657            .await
658            .unwrap();
659
660        assert_eq!(result.id, Some(cipher_id));
661        assert_eq!(result.folder_id, Some(new_folder_id));
662        assert!(result.favorite);
663        // Partial endpoint omits collection_ids; they must be preserved from the original.
664        assert_eq!(result.collection_ids, vec![collection_id]);
665    }
666
667    #[tokio::test]
668    async fn test_edit_partial_cipher_does_not_exist() {
669        let store: KeyStore<KeySlotIds> = KeyStore::default();
670
671        let repository = MemoryRepository::<Cipher>::default();
672        let api_client = ApiClient::new_mocked(|_| {});
673
674        let request = CipherPartialEditRequest {
675            id: TEST_CIPHER_ID.parse().unwrap(),
676            folder_id: None,
677            favorite: false,
678        };
679
680        let result = partial_edit_cipher(&store, &api_client, &repository, request, false).await;
681
682        assert!(matches!(
683            result.unwrap_err(),
684            EditCipherError::ItemNotFound(_)
685        ));
686    }
687
688    #[tokio::test]
689    async fn test_edit_cipher_does_not_exist() {
690        let store: KeyStore<KeySlotIds> = KeyStore::default();
691
692        let repository = MemoryRepository::<Cipher>::default();
693
694        let cipher_view = generate_test_cipher();
695        let api_client = ApiClient::new_mocked(|_| {});
696
697        let request = cipher_view.try_into().unwrap();
698
699        let result = edit_cipher(
700            &store,
701            &api_client,
702            &repository,
703            TEST_USER_ID.parse().unwrap(),
704            request,
705            false,
706            false,
707            false,
708        )
709        .await;
710
711        assert!(result.is_err());
712        assert!(matches!(
713            result.unwrap_err(),
714            EditCipherError::ItemNotFound(_)
715        ));
716    }
717
718    #[tokio::test]
719    async fn test_edit_cipher_http_error() {
720        let store: KeyStore<KeySlotIds> = KeyStore::default();
721        {
722            let mut ctx = store.context_mut();
723            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
724            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
725                .unwrap();
726        }
727
728        let cipher_id: CipherId = "5faa9684-c793-4a2d-8a12-b33900187097".parse().unwrap();
729
730        let api_client = ApiClient::new_mocked(move |mock| {
731            mock.ciphers_api
732                .expect_put()
733                .returning(move |_id, _body| Err(std::io::Error::other("Simulated error").into()));
734        });
735
736        let repository = MemoryRepository::<Cipher>::default();
737        repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
738        let cipher_view = generate_test_cipher();
739
740        let request = cipher_view.try_into().unwrap();
741
742        let result = edit_cipher(
743            &store,
744            &api_client,
745            &repository,
746            TEST_USER_ID.parse().unwrap(),
747            request,
748            false,
749            false,
750            false,
751        )
752        .await;
753
754        assert!(result.is_err());
755        assert!(matches!(result.unwrap_err(), EditCipherError::Api(_)));
756    }
757
758    /// Build the edit-side view the way the flow does: request → view, then
759    /// fold in password history against the decrypted original.
760    fn edit_view_with_history(new_cipher: CipherView, original: &CipherView) -> CipherView {
761        let mut view: CipherView =
762            convert_request_to_cipher_view(CipherEditRequest::try_from(new_cipher).unwrap());
763        view.update_password_history(original);
764        view
765    }
766
767    #[test]
768    fn test_password_history_on_password_change() {
769        let original_cipher = create_test_login_cipher("old_password");
770
771        let start = Utc::now();
772        let view =
773            edit_view_with_history(create_test_login_cipher("new_password"), &original_cipher);
774        let end = Utc::now();
775        let history = view.password_history.unwrap_or_default();
776
777        assert_eq!(history.len(), 1);
778        assert!(
779            history[0].last_used_date >= start && history[0].last_used_date <= end,
780            "last_used_date was not set properly"
781        );
782        assert_eq!(history[0].password, "old_password");
783    }
784
785    #[test]
786    fn test_password_history_on_unchanged_password() {
787        let original_cipher = create_test_login_cipher("same_password");
788        let view =
789            edit_view_with_history(create_test_login_cipher("same_password"), &original_cipher);
790
791        assert!(view.password_history.unwrap_or_default().is_empty());
792    }
793
794    #[test]
795    fn test_password_history_is_preserved() {
796        let mut original_cipher = create_test_login_cipher("same_password");
797        original_cipher.password_history = Some(
798            (0..4)
799                .map(|i| PasswordHistoryView {
800                    password: format!("old_password_{}", i),
801                    last_used_date: Utc.with_ymd_and_hms(2025, i + 1, i + 1, i, i, i).unwrap(),
802                })
803                .collect(),
804        );
805
806        let view =
807            edit_view_with_history(create_test_login_cipher("same_password"), &original_cipher);
808        let history = view.password_history.unwrap_or_default();
809
810        assert_eq!(history[0].password, "old_password_0");
811
812        assert_eq!(
813            history[0].last_used_date,
814            Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
815        );
816        assert_eq!(history[1].password, "old_password_1");
817        assert_eq!(
818            history[1].last_used_date,
819            Utc.with_ymd_and_hms(2025, 2, 2, 1, 1, 1).unwrap()
820        );
821        assert_eq!(history[2].password, "old_password_2");
822        assert_eq!(
823            history[2].last_used_date,
824            Utc.with_ymd_and_hms(2025, 3, 3, 2, 2, 2).unwrap()
825        );
826        assert_eq!(history[3].password, "old_password_3");
827        assert_eq!(
828            history[3].last_used_date,
829            Utc.with_ymd_and_hms(2025, 4, 4, 3, 3, 3).unwrap()
830        );
831    }
832
833    #[test]
834    fn test_password_history_with_hidden_fields() {
835        let mut original_cipher = create_test_login_cipher("password");
836        original_cipher.fields = Some(vec![FieldView {
837            name: Some("Secret Key".to_string()),
838            value: Some("old_secret_value".to_string()),
839            r#type: FieldType::Hidden,
840            linked_id: None,
841        }]);
842
843        let mut new_cipher = create_test_login_cipher("password");
844        new_cipher.fields = Some(vec![FieldView {
845            name: Some("Secret Key".to_string()),
846            value: Some("new_secret_value".to_string()),
847            r#type: FieldType::Hidden,
848            linked_id: None,
849        }]);
850
851        let view = edit_view_with_history(new_cipher, &original_cipher);
852        let history = view.password_history.unwrap_or_default();
853
854        assert_eq!(history.len(), 1);
855        assert_eq!(history[0].password, "Secret Key: old_secret_value");
856    }
857
858    #[test]
859    fn test_password_history_length_limit() {
860        let mut original_cipher = create_test_login_cipher("password");
861        original_cipher.password_history = Some(
862            (0..10)
863                .map(|i| PasswordHistoryView {
864                    password: format!("old_password_{}", i),
865                    last_used_date: Utc::now(),
866                })
867                .collect(),
868        );
869
870        let view =
871            edit_view_with_history(create_test_login_cipher("new_password"), &original_cipher);
872        let history = view.password_history.unwrap_or_default();
873
874        assert_eq!(history.len(), MAX_PASSWORD_HISTORY_ENTRIES);
875        // Most recent change (original password) should be first
876        assert_eq!(history[0].password, "password");
877
878        assert_eq!(history[1].password, "old_password_0");
879        assert_eq!(history[2].password, "old_password_1");
880        assert_eq!(history[3].password, "old_password_2");
881        assert_eq!(history[4].password, "old_password_3");
882    }
883
884    mod blob_encrypt {
885        use bitwarden_core::key_management::create_test_crypto_with_user_key;
886        use bitwarden_crypto::SymmetricCryptoKey;
887
888        use super::*;
889        use crate::cipher::blob::try_parse_blob;
890
891        /// `EncryptMode::Blob(CipherView)` clears `password_history` from the
892        /// wire-shaped `Cipher` — history must travel inside the sealed blob,
893        /// not as a top-level encrypted field.
894        #[test]
895        fn password_history_lives_inside_blob_not_on_wire() {
896            let store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
897                SymmetricKeyAlgorithm::Aes256CbcHmac,
898            ));
899
900            let original = create_test_login_cipher("old_password");
901            let mut view = create_test_login_cipher("new_password");
902            view.update_password_history(&original);
903            // Sanity: the in-flight view captured the old password.
904            assert_eq!(view.password_history.as_ref().unwrap().len(), 1);
905
906            let cipher: Cipher = store.encrypt(EncryptMode::Blob(view)).unwrap();
907
908            assert!(try_parse_blob(&cipher).is_some());
909            assert!(
910                cipher.password_history.is_none(),
911                "password history must live inside the blob, not on the wire",
912            );
913            assert!(cipher.login.is_none());
914            assert!(cipher.notes.is_none());
915        }
916
917        /// End-to-end: a password change picked up by `update_password_history`
918        /// is sealed inside the blob and unsealed back out by
919        /// `BlobAwareDecrypt`.
920        #[test]
921        fn password_history_round_trips_through_the_blob() {
922            let store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
923                SymmetricKeyAlgorithm::Aes256CbcHmac,
924            ));
925
926            let original = create_test_login_cipher("old_password");
927            let mut view = create_test_login_cipher("new_password");
928            view.update_password_history(&original);
929
930            let cipher: Cipher = store.encrypt(EncryptMode::Blob(view)).unwrap();
931            let restored: CipherView = store.decrypt(&cipher).unwrap();
932
933            let history = restored
934                .password_history
935                .expect("history should round-trip through the blob");
936            assert_eq!(history.len(), 1);
937            assert_eq!(history[0].password, "old_password");
938        }
939    }
940}