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
50/// Request to edit a cipher.
51#[derive(Clone, Serialize, Deserialize, Debug)]
52#[serde(rename_all = "camelCase")]
53#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
54#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
55pub struct CipherEditRequest {
56    pub id: CipherId,
57
58    pub organization_id: Option<OrganizationId>,
59    pub folder_id: Option<FolderId>,
60    pub favorite: bool,
61    pub reprompt: CipherRepromptType,
62    pub name: String,
63    pub notes: Option<String>,
64    pub fields: Vec<FieldView>,
65    pub r#type: CipherViewType,
66    pub revision_date: DateTime<Utc>,
67    pub archived_date: Option<DateTime<Utc>>,
68    pub attachments: Vec<AttachmentView>,
69    pub key: Option<EncString>,
70}
71
72impl TryFrom<CipherView> for CipherEditRequest {
73    type Error = MissingFieldError;
74
75    fn try_from(value: CipherView) -> Result<Self, Self::Error> {
76        let type_data = match value.r#type {
77            CipherType::Login => value.login.map(CipherViewType::Login),
78            CipherType::SecureNote => value.secure_note.map(CipherViewType::SecureNote),
79            CipherType::Card => value.card.map(CipherViewType::Card),
80            CipherType::Identity => value.identity.map(CipherViewType::Identity),
81            CipherType::SshKey => value.ssh_key.map(CipherViewType::SshKey),
82            CipherType::BankAccount => value.bank_account.map(CipherViewType::BankAccount),
83            CipherType::DriversLicense => value.drivers_license.map(CipherViewType::DriversLicense),
84            CipherType::Passport => value.passport.map(CipherViewType::Passport),
85        };
86        Ok(Self {
87            id: value.id.ok_or(MissingFieldError("id"))?,
88            organization_id: value.organization_id,
89            folder_id: value.folder_id,
90            favorite: value.favorite,
91            reprompt: value.reprompt,
92            key: value.key,
93            name: value.name,
94            notes: value.notes,
95            fields: value.fields.unwrap_or_default(),
96            r#type: require!(type_data),
97            attachments: value.attachments.unwrap_or_default(),
98            revision_date: value.revision_date,
99            archived_date: value.archived_date,
100        })
101    }
102}
103
104/// Request to update the subset of cipher fields that a user without edit
105/// permissions is still allowed to change (`folder_id` and `favorite`).
106///
107/// Backed by the `PUT /ciphers/{id}/partial` server endpoint, which authorizes
108/// based on view (not edit) access.
109#[derive(Clone, Serialize, Deserialize, Debug)]
110#[serde(rename_all = "camelCase")]
111#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
112#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
113pub struct CipherPartialEditRequest {
114    pub id: CipherId,
115    pub folder_id: Option<FolderId>,
116    pub favorite: bool,
117}
118
119/// Internal helper to convert a [`CipherEditRequest`] into a [`CipherView`]
120/// so the existing `CipherView` encryption pipeline can be reused.
121///
122/// This conversion is lossy and intended for use only within the edit flow,
123/// as the `CipherView` produced will not have all fields populated (e.g. `collection_ids`).
124pub(crate) fn convert_request_to_cipher_view(r: CipherEditRequest) -> CipherView {
125    CipherView {
126        id: Some(r.id),
127        organization_id: r.organization_id,
128        folder_id: r.folder_id,
129        // `collection_ids` is empty because collections are updated via a separate endpoint.
130        collection_ids: vec![],
131        key: r.key,
132        name: r.name,
133        notes: r.notes,
134        r#type: r.r#type.get_cipher_type(),
135        login: r.r#type.as_login_view().cloned(),
136        identity: r.r#type.as_identity_view().cloned(),
137        card: r.r#type.as_card_view().cloned(),
138        secure_note: r.r#type.as_secure_note_view().cloned(),
139        ssh_key: r.r#type.as_ssh_key_view().cloned(),
140        bank_account: r.r#type.as_bank_account_view().cloned(),
141        drivers_license: r.r#type.as_drivers_license_view().cloned(),
142        passport: r.r#type.as_passport_view().cloned(),
143        favorite: r.favorite,
144        reprompt: r.reprompt,
145        organization_use_totp: false,
146        edit: true,
147        permissions: None,
148        view_password: true,
149        local_data: None,
150        attachments: Some(r.attachments),
151        attachment_decryption_failures: None,
152        fields: Some(r.fields),
153        password_history: None,
154        // `creation_date` is overwritten by the server on merge
155        creation_date: Utc::now(),
156        deleted_date: None,
157        revision_date: r.revision_date,
158        archived_date: r.archived_date,
159    }
160}
161
162// `use_strict_decryption`, `enable_cipher_key_encryption`, and `use_blob` are
163// short-lived feature-rollout flags that will be removed once their migrations
164// complete, at which point the argument count drops back under the limit.
165#[allow(clippy::too_many_arguments)]
166async fn edit_cipher<R: Repository<Cipher> + ?Sized>(
167    key_store: &KeyStore<KeySlotIds>,
168    api_client: &bitwarden_api_api::apis::ApiClient,
169    repository: &R,
170    encrypted_for: UserId,
171    request: CipherEditRequest,
172    use_strict_decryption: bool,
173    enable_cipher_key_encryption: bool,
174    use_blob: bool,
175) -> Result<CipherView, EditCipherError> {
176    let cipher_id = request.id;
177
178    let original_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
179    let original_cipher_view: CipherView = if use_strict_decryption {
180        key_store.decrypt(&StrictDecrypt(original_cipher.clone()))?
181    } else {
182        key_store.decrypt(&original_cipher)?
183    };
184
185    let mut view: CipherView = convert_request_to_cipher_view(request);
186    view.update_password_history(&original_cipher_view);
187
188    // TODO: Once this flag is removed, the key generation logic should be
189    // moved directly into the CompositeEncryptable implementation.
190    if view.key.is_none() && enable_cipher_key_encryption {
191        let key = view.key_identifier();
192        view.generate_cipher_key(&mut key_store.context(), key)?;
193    }
194
195    let encrypted_by_key_id = key_store
196        .context()
197        .get_symmetric_key_id(view.key_identifier())
198        .map(|id| id.to_string());
199
200    let mode = if use_blob {
201        EncryptMode::Blob(view)
202    } else {
203        EncryptMode::Legacy(view)
204    };
205
206    let cipher: Cipher = key_store.encrypt(mode)?;
207    let mut cipher_request: CipherRequestModel = cipher.try_into()?;
208    cipher_request.encrypted_for = Some(encrypted_for.into());
209    cipher_request.encrypted_by_key_id = encrypted_by_key_id;
210
211    let cipher: Cipher = api_client
212        .ciphers_api()
213        .put(cipher_id.into(), Some(cipher_request))
214        .await?
215        .merge_with_cipher(Some(original_cipher))?;
216    debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
217    repository.set(cipher_id, cipher.clone()).await?;
218
219    Ok(if use_strict_decryption {
220        key_store.decrypt(&StrictDecrypt(cipher))?
221    } else {
222        key_store.decrypt(&cipher)?
223    })
224}
225
226/// Update only the cipher fields available to users without edit permissions
227/// (`folder_id` and `favorite`) via the server's partial-update endpoint.
228async fn partial_edit_cipher<R: Repository<Cipher> + ?Sized>(
229    key_store: &KeyStore<KeySlotIds>,
230    api_client: &bitwarden_api_api::apis::ApiClient,
231    repository: &R,
232    request: CipherPartialEditRequest,
233    use_strict_decryption: bool,
234) -> Result<CipherView, EditCipherError> {
235    let cipher_id = request.id;
236
237    let original_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
238
239    let partial_request = CipherPartialRequestModel {
240        folder_id: request.folder_id.map(|id| id.to_string()),
241        favorite: Some(request.favorite),
242    };
243
244    let cipher: Cipher = api_client
245        .ciphers_api()
246        .put_partial(cipher_id.into(), Some(partial_request))
247        .await?
248        .merge_with_cipher(Some(original_cipher))?;
249    debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
250    repository.set(cipher_id, cipher.clone()).await?;
251
252    Ok(if use_strict_decryption {
253        key_store.decrypt(&StrictDecrypt(cipher))?
254    } else {
255        key_store.decrypt(&cipher)?
256    })
257}
258
259#[allow(deprecated)]
260#[cfg_attr(feature = "wasm", wasm_bindgen)]
261impl CiphersClient {
262    /// Edit an existing [Cipher] and save it to the server.
263    pub async fn edit(&self, request: CipherEditRequest) -> Result<CipherView, EditCipherError> {
264        let key_store = self.client.internal.get_key_store();
265        let config = self.client.internal.get_api_configurations();
266        let repository = self.get_repository()?;
267
268        let user_id = self
269            .client
270            .internal
271            .get_user_id()
272            .ok_or(NotAuthenticatedError)?;
273
274        let enable_cipher_key_encryption =
275            self.client.flags().get().await.enable_cipher_key_encryption;
276
277        let use_blob = self.should_use_blob_encryption(request.organization_id);
278
279        edit_cipher(
280            key_store,
281            &config.api_client,
282            repository.as_ref(),
283            user_id,
284            request,
285            self.is_strict_decrypt().await,
286            enable_cipher_key_encryption,
287            use_blob,
288        )
289        .await
290    }
291
292    /// Update only `folder_id` and `favorite` on an existing [Cipher].
293    ///
294    /// Intended for users who do not have edit permissions on the cipher, but
295    /// are still allowed to change these personal organization fields.
296    pub async fn edit_partial(
297        &self,
298        request: CipherPartialEditRequest,
299    ) -> Result<CipherView, EditCipherError> {
300        let key_store = self.client.internal.get_key_store();
301        let config = self.client.internal.get_api_configurations();
302        let repository = self.get_repository()?;
303
304        partial_edit_cipher(
305            key_store,
306            &config.api_client,
307            repository.as_ref(),
308            request,
309            self.is_strict_decrypt().await,
310        )
311        .await
312    }
313
314    /// Adds the cipher matched by [CipherId] to any number of collections on the server.
315    pub async fn update_collection(
316        &self,
317        cipher_id: CipherId,
318        collection_ids: Vec<CollectionId>,
319        is_admin: bool,
320    ) -> Result<CipherView, EditCipherError> {
321        let req = CipherCollectionsRequestModel {
322            collection_ids: collection_ids
323                .into_iter()
324                .map(|id| id.to_string())
325                .collect(),
326        };
327        let repository = self.get_repository()?;
328
329        let api_config = self.client.internal.get_api_configurations();
330        let api = api_config.api_client.ciphers_api();
331        let orig_cipher = repository.get(cipher_id).await?;
332        let cipher = if is_admin {
333            api.put_collections_admin(&cipher_id.to_string(), Some(req))
334                .await?
335                .merge_with_cipher(orig_cipher)?
336        } else {
337            let cipher_response = api
338                .put_collections_v_next(cipher_id.into(), Some(req))
339                .await?
340                .cipher
341                .map(|c| *c)
342                .ok_or(MissingFieldError("cipher"))?;
343            let response: Cipher = cipher_response.merge_with_cipher(orig_cipher)?;
344            repository.set(cipher_id, response.clone()).await?;
345            response
346        };
347
348        Ok(self
349            .decrypt(cipher)
350            .await
351            .map_err(|_| CryptoError::KeyDecrypt)?)
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel};
358    use bitwarden_core::key_management::SymmetricKeySlotId;
359    use bitwarden_crypto::{KeyStore, PrimitiveEncryptable, SymmetricKeyAlgorithm};
360    use bitwarden_test::MemoryRepository;
361    use chrono::TimeZone;
362
363    use super::*;
364    use crate::{
365        Cipher, CipherId, CipherRepromptType, CipherType, FieldType, Login, LoginView,
366        PasswordHistoryView, password_history::MAX_PASSWORD_HISTORY_ENTRIES,
367    };
368
369    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
370    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
371
372    fn generate_test_cipher() -> CipherView {
373        CipherView {
374            id: Some(TEST_CIPHER_ID.parse().unwrap()),
375            organization_id: None,
376            folder_id: None,
377            collection_ids: vec![],
378            key: None,
379            name: "Test Login".to_string(),
380            notes: None,
381            r#type: CipherType::Login,
382            login: Some(LoginView {
383                username: Some("[email protected]".to_string()),
384                password: Some("password123".to_string()),
385                password_revision_date: None,
386                uris: None,
387                totp: None,
388                autofill_on_page_load: None,
389                fido2_credentials: None,
390            }),
391            identity: None,
392            card: None,
393            secure_note: None,
394            ssh_key: None,
395            bank_account: None,
396            passport: None,
397            drivers_license: None,
398            favorite: false,
399            reprompt: CipherRepromptType::None,
400            organization_use_totp: true,
401            edit: true,
402            permissions: None,
403            view_password: true,
404            local_data: None,
405            attachments: None,
406            attachment_decryption_failures: None,
407            fields: None,
408            password_history: None,
409            creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
410            deleted_date: None,
411            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
412            archived_date: None,
413        }
414    }
415
416    fn create_test_login_cipher(password: &str) -> CipherView {
417        let mut cipher_view = generate_test_cipher();
418        if let Some(ref mut login) = cipher_view.login {
419            login.password = Some(password.to_string());
420        }
421        cipher_view
422    }
423
424    async fn repository_add_cipher(
425        repository: &MemoryRepository<Cipher>,
426        store: &KeyStore<KeySlotIds>,
427        cipher_id: CipherId,
428        name: &str,
429    ) {
430        let cipher = {
431            let mut ctx = store.context();
432
433            Cipher {
434                id: Some(cipher_id),
435                organization_id: None,
436                folder_id: None,
437                collection_ids: vec![],
438                key: None,
439                name: Some(name.encrypt(&mut ctx, SymmetricKeySlotId::User).unwrap()),
440                notes: None,
441                r#type: CipherType::Login,
442                login: Some(Login {
443                    username: Some("[email protected]")
444                        .map(|u| u.encrypt(&mut ctx, SymmetricKeySlotId::User))
445                        .transpose()
446                        .unwrap(),
447                    password: Some("password123")
448                        .map(|p| p.encrypt(&mut ctx, SymmetricKeySlotId::User))
449                        .transpose()
450                        .unwrap(),
451                    password_revision_date: None,
452                    uris: None,
453                    totp: None,
454                    autofill_on_page_load: None,
455                    fido2_credentials: None,
456                }),
457                identity: None,
458                card: None,
459                secure_note: None,
460                ssh_key: None,
461                bank_account: None,
462                drivers_license: None,
463                passport: None,
464                favorite: false,
465                reprompt: CipherRepromptType::None,
466                organization_use_totp: true,
467                edit: true,
468                permissions: None,
469                view_password: true,
470                local_data: None,
471                attachments: None,
472                fields: None,
473                password_history: None,
474                creation_date: "2024-01-01T00:00:00Z".parse().unwrap(),
475                deleted_date: None,
476                revision_date: "2024-01-01T00:00:00Z".parse().unwrap(),
477                archived_date: None,
478                data: None,
479            }
480        };
481
482        repository.set(cipher_id, cipher).await.unwrap();
483    }
484
485    #[tokio::test]
486    async fn test_edit_cipher() {
487        let store: KeyStore<KeySlotIds> = KeyStore::default();
488        {
489            let mut ctx = store.context_mut();
490            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
491            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
492                .unwrap();
493        }
494
495        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
496
497        let api_client = ApiClient::new_mocked(move |mock| {
498            mock.ciphers_api
499                .expect_put()
500                .returning(move |_id, body| {
501                    let body = body.unwrap();
502                    Ok(CipherResponseModel {
503                        object: Some("cipher".to_string()),
504                        id: Some(cipher_id.into()),
505                        name: Some(body.name),
506                        r#type: body.r#type,
507                        organization_id: body
508                            .organization_id
509                            .as_ref()
510                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
511                        folder_id: body
512                            .folder_id
513                            .as_ref()
514                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
515                        favorite: body.favorite,
516                        reprompt: body.reprompt,
517                        key: body.key,
518                        notes: body.notes,
519                        view_password: Some(true),
520                        edit: Some(true),
521                        organization_use_totp: Some(true),
522                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
523                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
524                        deleted_date: None,
525                        login: body.login,
526                        card: body.card,
527                        identity: body.identity,
528                        secure_note: body.secure_note,
529                        ssh_key: body.ssh_key,
530                        bank_account: body.bank_account,
531                        drivers_license: body.drivers_license,
532                        passport: body.passport,
533                        fields: body.fields,
534                        password_history: body.password_history,
535                        attachments: None,
536                        permissions: None,
537                        data: None,
538                        partial_data: None,
539                        archived_date: None,
540                    })
541                })
542                .once();
543        });
544
545        let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
546
547        let repository = MemoryRepository::<Cipher>::default();
548        repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
549        // Update the stored cipher to include a collection_id so we can verify it is preserved.
550        let mut stored = repository.get(cipher_id).await.unwrap().unwrap();
551        stored.collection_ids = vec![collection_id];
552        repository.set(cipher_id, stored).await.unwrap();
553
554        let cipher_view = generate_test_cipher();
555
556        let request = cipher_view.try_into().unwrap();
557
558        let result = edit_cipher(
559            &store,
560            &api_client,
561            &repository,
562            TEST_USER_ID.parse().unwrap(),
563            request,
564            false,
565            false,
566            false,
567        )
568        .await
569        .unwrap();
570
571        assert_eq!(result.id, Some(cipher_id));
572        assert_eq!(result.name, "Test Login");
573        // collection_ids must be preserved even though CipherResponseModel omits them.
574        assert_eq!(result.collection_ids, vec![collection_id]);
575    }
576
577    #[tokio::test]
578    async fn test_edit_partial_cipher() {
579        let store: KeyStore<KeySlotIds> = KeyStore::default();
580        {
581            let mut ctx = store.context_mut();
582            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
583            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
584                .unwrap();
585        }
586
587        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
588        let new_folder_id: FolderId = "9b1e7c8f-3a04-4d2e-9d1e-b18100abcdef".parse().unwrap();
589
590        let api_client = ApiClient::new_mocked(move |mock| {
591            mock.ciphers_api
592                .expect_put_partial()
593                .returning(move |id, body| {
594                    let body = body.unwrap();
595                    let expected_id: uuid::Uuid = cipher_id.into();
596                    assert_eq!(id, expected_id);
597                    assert_eq!(body.favorite, Some(true));
598                    assert_eq!(
599                        body.folder_id.as_deref(),
600                        Some(new_folder_id.to_string().as_str())
601                    );
602                    Ok(CipherResponseModel {
603                        object: Some("cipher".to_string()),
604                        id: Some(cipher_id.into()),
605                        name: Some(
606                            "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".to_string(),
607                        ),
608                        r#type: Some(bitwarden_api_api::models::CipherType::Login),
609                        organization_id: None,
610                        folder_id: Some(new_folder_id.into()),
611                        favorite: Some(true),
612                        reprompt: Some(bitwarden_api_api::models::CipherRepromptType::None),
613                        key: None,
614                        notes: None,
615                        view_password: Some(true),
616                        edit: Some(false),
617                        organization_use_totp: Some(true),
618                        revision_date: Some("2025-01-02T00:00:00Z".to_string()),
619                        creation_date: Some("2024-01-01T00:00:00Z".to_string()),
620                        deleted_date: None,
621                        login: None,
622                        card: None,
623                        identity: None,
624                        secure_note: None,
625                        ssh_key: None,
626                        bank_account: None,
627                        drivers_license: None,
628                        passport: None,
629                        fields: None,
630                        password_history: None,
631                        attachments: None,
632                        permissions: None,
633                        data: None,
634                        partial_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}