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::{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
168async fn edit_cipher<R: Repository<Cipher> + ?Sized>(
169    key_store: &KeyStore<KeySlotIds>,
170    api_client: &bitwarden_api_api::apis::ApiClient,
171    repository: &R,
172    encrypted_for: UserId,
173    request: CipherEditRequest,
174    use_strict_decryption: bool,
175    enable_cipher_key_encryption: bool,
176) -> Result<CipherView, EditCipherError> {
177    let cipher_id = request.id;
178
179    let original_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
180    let original_cipher_view: CipherView = if use_strict_decryption {
181        key_store.decrypt(&StrictDecrypt(original_cipher.clone()))?
182    } else {
183        key_store.decrypt(&original_cipher)?
184    };
185
186    let mut view: CipherView = convert_request_to_cipher_view(request);
187    view.update_password_history(&original_cipher_view);
188
189    // TODO: Once this flag is removed, the key generation logic should be
190    // moved directly into the CompositeEncryptable implementation.
191    if view.key.is_none() && enable_cipher_key_encryption {
192        let key = view.key_identifier();
193        view.generate_cipher_key(&mut key_store.context(), key)?;
194    }
195
196    let cipher: Cipher = key_store.encrypt(view)?;
197    let mut cipher_request: CipherRequestModel = cipher.try_into()?;
198    cipher_request.encrypted_for = Some(encrypted_for.into());
199
200    let cipher: Cipher = api_client
201        .ciphers_api()
202        .put(cipher_id.into(), Some(cipher_request))
203        .await
204        .map_err(ApiError::from)?
205        .merge_with_cipher(Some(original_cipher))?;
206    debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
207    repository.set(cipher_id, cipher.clone()).await?;
208
209    if use_strict_decryption {
210        Ok(key_store.decrypt(&StrictDecrypt(cipher))?)
211    } else {
212        Ok(key_store.decrypt(&cipher)?)
213    }
214}
215
216/// Update only the cipher fields available to users without edit permissions
217/// (`folder_id` and `favorite`) via the server's partial-update endpoint.
218async fn partial_edit_cipher<R: Repository<Cipher> + ?Sized>(
219    key_store: &KeyStore<KeySlotIds>,
220    api_client: &bitwarden_api_api::apis::ApiClient,
221    repository: &R,
222    request: CipherPartialEditRequest,
223    use_strict_decryption: bool,
224) -> Result<CipherView, EditCipherError> {
225    let cipher_id = request.id;
226
227    let original_cipher = repository.get(cipher_id).await?.ok_or(ItemNotFoundError)?;
228
229    let partial_request = CipherPartialRequestModel {
230        folder_id: request.folder_id.map(|id| id.to_string()),
231        favorite: Some(request.favorite),
232    };
233
234    let cipher: Cipher = api_client
235        .ciphers_api()
236        .put_partial(cipher_id.into(), Some(partial_request))
237        .await
238        .map_err(ApiError::from)?
239        .merge_with_cipher(Some(original_cipher))?;
240    debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
241    repository.set(cipher_id, cipher.clone()).await?;
242
243    if use_strict_decryption {
244        Ok(key_store.decrypt(&StrictDecrypt(cipher))?)
245    } else {
246        Ok(key_store.decrypt(&cipher)?)
247    }
248}
249
250#[allow(deprecated)]
251#[cfg_attr(feature = "wasm", wasm_bindgen)]
252impl CiphersClient {
253    /// Edit an existing [Cipher] and save it to the server.
254    pub async fn edit(&self, request: CipherEditRequest) -> Result<CipherView, EditCipherError> {
255        let key_store = self.client.internal.get_key_store();
256        let config = self.client.internal.get_api_configurations();
257        let repository = self.get_repository()?;
258
259        let user_id = self
260            .client
261            .internal
262            .get_user_id()
263            .ok_or(NotAuthenticatedError)?;
264
265        let enable_cipher_key_encryption =
266            self.client.flags().get().await.enable_cipher_key_encryption;
267
268        edit_cipher(
269            key_store,
270            &config.api_client,
271            repository.as_ref(),
272            user_id,
273            request,
274            self.is_strict_decrypt().await,
275            enable_cipher_key_encryption,
276        )
277        .await
278    }
279
280    /// Update only `folder_id` and `favorite` on an existing [Cipher].
281    ///
282    /// Intended for users who do not have edit permissions on the cipher, but
283    /// are still allowed to change these personal organization fields.
284    pub async fn edit_partial(
285        &self,
286        request: CipherPartialEditRequest,
287    ) -> Result<CipherView, EditCipherError> {
288        let key_store = self.client.internal.get_key_store();
289        let config = self.client.internal.get_api_configurations();
290        let repository = self.get_repository()?;
291
292        partial_edit_cipher(
293            key_store,
294            &config.api_client,
295            repository.as_ref(),
296            request,
297            self.is_strict_decrypt().await,
298        )
299        .await
300    }
301
302    /// Adds the cipher matched by [CipherId] to any number of collections on the server.
303    pub async fn update_collection(
304        &self,
305        cipher_id: CipherId,
306        collection_ids: Vec<CollectionId>,
307        is_admin: bool,
308    ) -> Result<CipherView, EditCipherError> {
309        let req = CipherCollectionsRequestModel {
310            collection_ids: collection_ids
311                .into_iter()
312                .map(|id| id.to_string())
313                .collect(),
314        };
315        let repository = self.get_repository()?;
316
317        let api_config = self.client.internal.get_api_configurations();
318        let api = api_config.api_client.ciphers_api();
319        let orig_cipher = repository.get(cipher_id).await?;
320        let cipher = if is_admin {
321            api.put_collections_admin(&cipher_id.to_string(), Some(req))
322                .await?
323                .merge_with_cipher(orig_cipher)?
324        } else {
325            let cipher_response = api
326                .put_collections_v_next(cipher_id.into(), Some(req))
327                .await?
328                .cipher
329                .map(|c| *c)
330                .ok_or(MissingFieldError("cipher"))?;
331            let response: Cipher = cipher_response.merge_with_cipher(orig_cipher)?;
332            repository.set(cipher_id, response.clone()).await?;
333            response
334        };
335
336        Ok(self
337            .decrypt(cipher)
338            .await
339            .map_err(|_| CryptoError::KeyDecrypt)?)
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel};
346    use bitwarden_core::key_management::SymmetricKeySlotId;
347    use bitwarden_crypto::{KeyStore, PrimitiveEncryptable, SymmetricKeyAlgorithm};
348    use bitwarden_test::MemoryRepository;
349    use chrono::TimeZone;
350
351    use super::*;
352    use crate::{
353        Cipher, CipherId, CipherRepromptType, CipherType, FieldType, Login, LoginView,
354        PasswordHistoryView, password_history::MAX_PASSWORD_HISTORY_ENTRIES,
355    };
356
357    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
358    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
359
360    fn generate_test_cipher() -> CipherView {
361        CipherView {
362            id: Some(TEST_CIPHER_ID.parse().unwrap()),
363            organization_id: None,
364            folder_id: None,
365            collection_ids: vec![],
366            key: None,
367            name: "Test Login".to_string(),
368            notes: None,
369            r#type: CipherType::Login,
370            login: Some(LoginView {
371                username: Some("[email protected]".to_string()),
372                password: Some("password123".to_string()),
373                password_revision_date: None,
374                uris: None,
375                totp: None,
376                autofill_on_page_load: None,
377                fido2_credentials: None,
378            }),
379            identity: None,
380            card: None,
381            secure_note: None,
382            ssh_key: None,
383            bank_account: None,
384            passport: None,
385            drivers_license: None,
386            favorite: false,
387            reprompt: CipherRepromptType::None,
388            organization_use_totp: true,
389            edit: true,
390            permissions: None,
391            view_password: true,
392            local_data: None,
393            attachments: None,
394            attachment_decryption_failures: None,
395            fields: None,
396            password_history: None,
397            creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
398            deleted_date: None,
399            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
400            archived_date: None,
401        }
402    }
403
404    fn create_test_login_cipher(password: &str) -> CipherView {
405        let mut cipher_view = generate_test_cipher();
406        if let Some(ref mut login) = cipher_view.login {
407            login.password = Some(password.to_string());
408        }
409        cipher_view
410    }
411
412    async fn repository_add_cipher(
413        repository: &MemoryRepository<Cipher>,
414        store: &KeyStore<KeySlotIds>,
415        cipher_id: CipherId,
416        name: &str,
417    ) {
418        let cipher = {
419            let mut ctx = store.context();
420
421            Cipher {
422                id: Some(cipher_id),
423                organization_id: None,
424                folder_id: None,
425                collection_ids: vec![],
426                key: None,
427                name: name.encrypt(&mut ctx, SymmetricKeySlotId::User).unwrap(),
428                notes: None,
429                r#type: CipherType::Login,
430                login: Some(Login {
431                    username: Some("[email protected]")
432                        .map(|u| u.encrypt(&mut ctx, SymmetricKeySlotId::User))
433                        .transpose()
434                        .unwrap(),
435                    password: Some("password123")
436                        .map(|p| p.encrypt(&mut ctx, SymmetricKeySlotId::User))
437                        .transpose()
438                        .unwrap(),
439                    password_revision_date: None,
440                    uris: None,
441                    totp: None,
442                    autofill_on_page_load: None,
443                    fido2_credentials: None,
444                }),
445                identity: None,
446                card: None,
447                secure_note: None,
448                ssh_key: None,
449                bank_account: None,
450                drivers_license: None,
451                passport: None,
452                favorite: false,
453                reprompt: CipherRepromptType::None,
454                organization_use_totp: true,
455                edit: true,
456                permissions: None,
457                view_password: true,
458                local_data: None,
459                attachments: None,
460                fields: None,
461                password_history: None,
462                creation_date: "2024-01-01T00:00:00Z".parse().unwrap(),
463                deleted_date: None,
464                revision_date: "2024-01-01T00:00:00Z".parse().unwrap(),
465                archived_date: None,
466                data: None,
467            }
468        };
469
470        repository.set(cipher_id, cipher).await.unwrap();
471    }
472
473    #[tokio::test]
474    async fn test_edit_cipher() {
475        let store: KeyStore<KeySlotIds> = KeyStore::default();
476        {
477            let mut ctx = store.context_mut();
478            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
479            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
480                .unwrap();
481        }
482
483        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
484
485        let api_client = ApiClient::new_mocked(move |mock| {
486            mock.ciphers_api
487                .expect_put()
488                .returning(move |_id, body| {
489                    let body = body.unwrap();
490                    Ok(CipherResponseModel {
491                        object: Some("cipher".to_string()),
492                        id: Some(cipher_id.into()),
493                        name: Some(body.name),
494                        r#type: body.r#type,
495                        organization_id: body
496                            .organization_id
497                            .as_ref()
498                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
499                        folder_id: body
500                            .folder_id
501                            .as_ref()
502                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
503                        favorite: body.favorite,
504                        reprompt: body.reprompt,
505                        key: body.key,
506                        notes: body.notes,
507                        view_password: Some(true),
508                        edit: Some(true),
509                        organization_use_totp: Some(true),
510                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
511                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
512                        deleted_date: None,
513                        login: body.login,
514                        card: body.card,
515                        identity: body.identity,
516                        secure_note: body.secure_note,
517                        ssh_key: body.ssh_key,
518                        bank_account: body.bank_account,
519                        drivers_license: body.drivers_license,
520                        passport: body.passport,
521                        fields: body.fields,
522                        password_history: body.password_history,
523                        attachments: None,
524                        permissions: None,
525                        data: None,
526                        archived_date: None,
527                    })
528                })
529                .once();
530        });
531
532        let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
533
534        let repository = MemoryRepository::<Cipher>::default();
535        repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
536        // Update the stored cipher to include a collection_id so we can verify it is preserved.
537        let mut stored = repository.get(cipher_id).await.unwrap().unwrap();
538        stored.collection_ids = vec![collection_id];
539        repository.set(cipher_id, stored).await.unwrap();
540
541        let cipher_view = generate_test_cipher();
542
543        let request = cipher_view.try_into().unwrap();
544
545        let result = edit_cipher(
546            &store,
547            &api_client,
548            &repository,
549            TEST_USER_ID.parse().unwrap(),
550            request,
551            false,
552            false,
553        )
554        .await
555        .unwrap();
556
557        assert_eq!(result.id, Some(cipher_id));
558        assert_eq!(result.name, "Test Login");
559        // collection_ids must be preserved even though CipherResponseModel omits them.
560        assert_eq!(result.collection_ids, vec![collection_id]);
561    }
562
563    #[tokio::test]
564    async fn test_edit_partial_cipher() {
565        let store: KeyStore<KeySlotIds> = KeyStore::default();
566        {
567            let mut ctx = store.context_mut();
568            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
569            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
570                .unwrap();
571        }
572
573        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
574        let new_folder_id: FolderId = "9b1e7c8f-3a04-4d2e-9d1e-b18100abcdef".parse().unwrap();
575
576        let api_client = ApiClient::new_mocked(move |mock| {
577            mock.ciphers_api
578                .expect_put_partial()
579                .returning(move |id, body| {
580                    let body = body.unwrap();
581                    let expected_id: uuid::Uuid = cipher_id.into();
582                    assert_eq!(id, expected_id);
583                    assert_eq!(body.favorite, Some(true));
584                    assert_eq!(
585                        body.folder_id.as_deref(),
586                        Some(new_folder_id.to_string().as_str())
587                    );
588                    Ok(CipherResponseModel {
589                        object: Some("cipher".to_string()),
590                        id: Some(cipher_id.into()),
591                        name: Some(
592                            "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".to_string(),
593                        ),
594                        r#type: Some(bitwarden_api_api::models::CipherType::Login),
595                        organization_id: None,
596                        folder_id: Some(new_folder_id.into()),
597                        favorite: Some(true),
598                        reprompt: Some(bitwarden_api_api::models::CipherRepromptType::None),
599                        key: None,
600                        notes: None,
601                        view_password: Some(true),
602                        edit: Some(false),
603                        organization_use_totp: Some(true),
604                        revision_date: Some("2025-01-02T00:00:00Z".to_string()),
605                        creation_date: Some("2024-01-01T00:00:00Z".to_string()),
606                        deleted_date: None,
607                        login: None,
608                        card: None,
609                        identity: None,
610                        secure_note: None,
611                        ssh_key: None,
612                        bank_account: None,
613                        drivers_license: None,
614                        passport: None,
615                        fields: None,
616                        password_history: None,
617                        attachments: None,
618                        permissions: None,
619                        data: None,
620                        archived_date: None,
621                    })
622                })
623                .once();
624        });
625
626        let collection_id: CollectionId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
627
628        let repository = MemoryRepository::<Cipher>::default();
629        repository_add_cipher(&repository, &store, cipher_id, "stored_name").await;
630        // Stamp a collection id to verify it is preserved across partial edit.
631        let mut stored = repository.get(cipher_id).await.unwrap().unwrap();
632        stored.collection_ids = vec![collection_id];
633        repository.set(cipher_id, stored).await.unwrap();
634
635        let request = CipherPartialEditRequest {
636            id: cipher_id,
637            folder_id: Some(new_folder_id),
638            favorite: true,
639        };
640
641        let result = partial_edit_cipher(&store, &api_client, &repository, request, false)
642            .await
643            .unwrap();
644
645        assert_eq!(result.id, Some(cipher_id));
646        assert_eq!(result.folder_id, Some(new_folder_id));
647        assert!(result.favorite);
648        // Partial endpoint omits collection_ids; they must be preserved from the original.
649        assert_eq!(result.collection_ids, vec![collection_id]);
650    }
651
652    #[tokio::test]
653    async fn test_edit_partial_cipher_does_not_exist() {
654        let store: KeyStore<KeySlotIds> = KeyStore::default();
655
656        let repository = MemoryRepository::<Cipher>::default();
657        let api_client = ApiClient::new_mocked(|_| {});
658
659        let request = CipherPartialEditRequest {
660            id: TEST_CIPHER_ID.parse().unwrap(),
661            folder_id: None,
662            favorite: false,
663        };
664
665        let result = partial_edit_cipher(&store, &api_client, &repository, request, false).await;
666
667        assert!(matches!(
668            result.unwrap_err(),
669            EditCipherError::ItemNotFound(_)
670        ));
671    }
672
673    #[tokio::test]
674    async fn test_edit_cipher_does_not_exist() {
675        let store: KeyStore<KeySlotIds> = KeyStore::default();
676
677        let repository = MemoryRepository::<Cipher>::default();
678
679        let cipher_view = generate_test_cipher();
680        let api_client = ApiClient::new_mocked(|_| {});
681
682        let request = cipher_view.try_into().unwrap();
683
684        let result = edit_cipher(
685            &store,
686            &api_client,
687            &repository,
688            TEST_USER_ID.parse().unwrap(),
689            request,
690            false,
691            false,
692        )
693        .await;
694
695        assert!(result.is_err());
696        assert!(matches!(
697            result.unwrap_err(),
698            EditCipherError::ItemNotFound(_)
699        ));
700    }
701
702    #[tokio::test]
703    async fn test_edit_cipher_http_error() {
704        let store: KeyStore<KeySlotIds> = KeyStore::default();
705        {
706            let mut ctx = store.context_mut();
707            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
708            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
709                .unwrap();
710        }
711
712        let cipher_id: CipherId = "5faa9684-c793-4a2d-8a12-b33900187097".parse().unwrap();
713
714        let api_client = ApiClient::new_mocked(move |mock| {
715            mock.ciphers_api
716                .expect_put()
717                .returning(move |_id, _body| Err(std::io::Error::other("Simulated error").into()));
718        });
719
720        let repository = MemoryRepository::<Cipher>::default();
721        repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
722        let cipher_view = generate_test_cipher();
723
724        let request = cipher_view.try_into().unwrap();
725
726        let result = edit_cipher(
727            &store,
728            &api_client,
729            &repository,
730            TEST_USER_ID.parse().unwrap(),
731            request,
732            false,
733            false,
734        )
735        .await;
736
737        assert!(result.is_err());
738        assert!(matches!(result.unwrap_err(), EditCipherError::Api(_)));
739    }
740
741    /// Build the edit-side view the way the flow does: request → view, then
742    /// fold in password history against the decrypted original.
743    fn edit_view_with_history(new_cipher: CipherView, original: &CipherView) -> CipherView {
744        let mut view: CipherView =
745            convert_request_to_cipher_view(CipherEditRequest::try_from(new_cipher).unwrap());
746        view.update_password_history(original);
747        view
748    }
749
750    #[test]
751    fn test_password_history_on_password_change() {
752        let original_cipher = create_test_login_cipher("old_password");
753
754        let start = Utc::now();
755        let view =
756            edit_view_with_history(create_test_login_cipher("new_password"), &original_cipher);
757        let end = Utc::now();
758        let history = view.password_history.unwrap_or_default();
759
760        assert_eq!(history.len(), 1);
761        assert!(
762            history[0].last_used_date >= start && history[0].last_used_date <= end,
763            "last_used_date was not set properly"
764        );
765        assert_eq!(history[0].password, "old_password");
766    }
767
768    #[test]
769    fn test_password_history_on_unchanged_password() {
770        let original_cipher = create_test_login_cipher("same_password");
771        let view =
772            edit_view_with_history(create_test_login_cipher("same_password"), &original_cipher);
773
774        assert!(view.password_history.unwrap_or_default().is_empty());
775    }
776
777    #[test]
778    fn test_password_history_is_preserved() {
779        let mut original_cipher = create_test_login_cipher("same_password");
780        original_cipher.password_history = Some(
781            (0..4)
782                .map(|i| PasswordHistoryView {
783                    password: format!("old_password_{}", i),
784                    last_used_date: Utc.with_ymd_and_hms(2025, i + 1, i + 1, i, i, i).unwrap(),
785                })
786                .collect(),
787        );
788
789        let view =
790            edit_view_with_history(create_test_login_cipher("same_password"), &original_cipher);
791        let history = view.password_history.unwrap_or_default();
792
793        assert_eq!(history[0].password, "old_password_0");
794
795        assert_eq!(
796            history[0].last_used_date,
797            Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
798        );
799        assert_eq!(history[1].password, "old_password_1");
800        assert_eq!(
801            history[1].last_used_date,
802            Utc.with_ymd_and_hms(2025, 2, 2, 1, 1, 1).unwrap()
803        );
804        assert_eq!(history[2].password, "old_password_2");
805        assert_eq!(
806            history[2].last_used_date,
807            Utc.with_ymd_and_hms(2025, 3, 3, 2, 2, 2).unwrap()
808        );
809        assert_eq!(history[3].password, "old_password_3");
810        assert_eq!(
811            history[3].last_used_date,
812            Utc.with_ymd_and_hms(2025, 4, 4, 3, 3, 3).unwrap()
813        );
814    }
815
816    #[test]
817    fn test_password_history_with_hidden_fields() {
818        let mut original_cipher = create_test_login_cipher("password");
819        original_cipher.fields = Some(vec![FieldView {
820            name: Some("Secret Key".to_string()),
821            value: Some("old_secret_value".to_string()),
822            r#type: FieldType::Hidden,
823            linked_id: None,
824        }]);
825
826        let mut new_cipher = create_test_login_cipher("password");
827        new_cipher.fields = Some(vec![FieldView {
828            name: Some("Secret Key".to_string()),
829            value: Some("new_secret_value".to_string()),
830            r#type: FieldType::Hidden,
831            linked_id: None,
832        }]);
833
834        let view = edit_view_with_history(new_cipher, &original_cipher);
835        let history = view.password_history.unwrap_or_default();
836
837        assert_eq!(history.len(), 1);
838        assert_eq!(history[0].password, "Secret Key: old_secret_value");
839    }
840
841    #[test]
842    fn test_password_history_length_limit() {
843        let mut original_cipher = create_test_login_cipher("password");
844        original_cipher.password_history = Some(
845            (0..10)
846                .map(|i| PasswordHistoryView {
847                    password: format!("old_password_{}", i),
848                    last_used_date: Utc::now(),
849                })
850                .collect(),
851        );
852
853        let view =
854            edit_view_with_history(create_test_login_cipher("new_password"), &original_cipher);
855        let history = view.password_history.unwrap_or_default();
856
857        assert_eq!(history.len(), MAX_PASSWORD_HISTORY_ENTRIES);
858        // Most recent change (original password) should be first
859        assert_eq!(history[0].password, "password");
860
861        assert_eq!(history[1].password, "old_password_0");
862        assert_eq!(history[2].password, "old_password_1");
863        assert_eq!(history[3].password, "old_password_2");
864        assert_eq!(history[4].password, "old_password_3");
865    }
866}