bitwarden_vault/cipher/cipher_client/
edit.rs

1use bitwarden_api_api::models::CipherRequestModel;
2use bitwarden_core::{
3    ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId,
4    key_management::{KeyIds, SymmetricKeyId},
5    require,
6};
7use bitwarden_crypto::{
8    CompositeEncryptable, CryptoError, EncString, IdentifyKey, KeyStore, KeyStoreContext,
9    PrimitiveEncryptable,
10};
11use bitwarden_error::bitwarden_error;
12use bitwarden_state::repository::{Repository, RepositoryError};
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16#[cfg(feature = "wasm")]
17use tsify::Tsify;
18#[cfg(feature = "wasm")]
19use wasm_bindgen::prelude::*;
20
21use super::CiphersClient;
22use crate::{
23    AttachmentView, Cipher, CipherId, CipherRepromptType, CipherType, CipherView, FieldView,
24    FolderId, ItemNotFoundError, PasswordHistoryView, VaultParseError,
25    cipher_view_type::CipherViewType, password_history::MAX_PASSWORD_HISTORY_ENTRIES,
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        };
83        Ok(Self {
84            id: value.id.ok_or(MissingFieldError("id"))?,
85            organization_id: value.organization_id,
86            folder_id: value.folder_id,
87            favorite: value.favorite,
88            reprompt: value.reprompt,
89            key: value.key,
90            name: value.name,
91            notes: value.notes,
92            fields: value.fields.unwrap_or_default(),
93            r#type: require!(type_data),
94            attachments: value.attachments.unwrap_or_default(),
95            revision_date: value.revision_date,
96            archived_date: value.archived_date,
97        })
98    }
99}
100
101impl CipherEditRequest {
102    fn generate_cipher_key(
103        &mut self,
104        ctx: &mut KeyStoreContext<KeyIds>,
105        key: SymmetricKeyId,
106    ) -> Result<(), CryptoError> {
107        let old_key = Cipher::decrypt_cipher_key(ctx, key, &self.key)?;
108
109        let new_key = ctx.generate_symmetric_key();
110
111        // Re-encrypt the internal fields with the new key
112        self.r#type
113            .as_login_view_mut()
114            .map(|l| l.reencrypt_fido2_credentials(ctx, old_key, new_key))
115            .transpose()?;
116        AttachmentView::reencrypt_keys(&mut self.attachments, ctx, old_key, new_key)?;
117        Ok(())
118    }
119}
120
121/// Used as an intermediary between the public-facing [CipherEditRequest], and the encrypted
122/// value. This allows us to calculate password history safely, without risking misuse.
123#[derive(Clone, Debug)]
124
125struct CipherEditRequestInternal {
126    edit_request: CipherEditRequest,
127    password_history: Vec<PasswordHistoryView>,
128}
129
130impl CipherEditRequestInternal {
131    fn new(edit_request: CipherEditRequest, orig_cipher: &CipherView) -> Self {
132        let mut internal_req = Self {
133            edit_request,
134            password_history: vec![],
135        };
136        internal_req.update_password_history(orig_cipher);
137
138        internal_req
139    }
140
141    fn update_password_history(&mut self, original_cipher: &CipherView) {
142        let changes = self
143            .detect_login_password_changes(original_cipher)
144            .into_iter()
145            .chain(self.detect_hidden_field_changes(original_cipher));
146        let history: Vec<_> = changes
147            .rev()
148            .chain(original_cipher.password_history.iter().flatten().cloned())
149            .take(MAX_PASSWORD_HISTORY_ENTRIES)
150            .collect();
151
152        self.password_history = history;
153    }
154
155    fn detect_login_password_changes(
156        &mut self,
157        original_cipher: &CipherView,
158    ) -> Vec<PasswordHistoryView> {
159        self.edit_request
160            .r#type
161            .as_login_view_mut()
162            .map_or(vec![], |login| {
163                login.detect_password_change(&original_cipher.login)
164            })
165    }
166
167    fn detect_hidden_field_changes(
168        &self,
169        original_cipher: &CipherView,
170    ) -> Vec<PasswordHistoryView> {
171        FieldView::detect_hidden_field_changes(
172            self.edit_request.fields.as_slice(),
173            original_cipher.fields.as_deref().unwrap_or(&[]),
174        )
175    }
176
177    fn generate_checksums(&mut self) {
178        if let Some(login) = &mut self.edit_request.r#type.as_login_view_mut() {
179            login.generate_checksums();
180        }
181    }
182}
183
184impl CompositeEncryptable<KeyIds, SymmetricKeyId, CipherRequestModel>
185    for CipherEditRequestInternal
186{
187    fn encrypt_composite(
188        &self,
189        ctx: &mut KeyStoreContext<KeyIds>,
190        key: SymmetricKeyId,
191    ) -> Result<CipherRequestModel, CryptoError> {
192        let mut cipher_data = (*self).clone();
193        cipher_data.generate_checksums();
194
195        let cipher_key = Cipher::decrypt_cipher_key(ctx, key, &self.edit_request.key)?;
196
197        let cipher_request = CipherRequestModel {
198            encrypted_for: None,
199            r#type: Some(cipher_data.edit_request.r#type.get_cipher_type().into()),
200            organization_id: cipher_data
201                .edit_request
202                .organization_id
203                .map(|id| id.to_string()),
204            folder_id: cipher_data.edit_request.folder_id.map(|id| id.to_string()),
205            favorite: Some(cipher_data.edit_request.favorite),
206            reprompt: Some(cipher_data.edit_request.reprompt.into()),
207            key: cipher_data.edit_request.key.map(|k| k.to_string()),
208            name: cipher_data
209                .edit_request
210                .name
211                .encrypt(ctx, cipher_key)?
212                .to_string(),
213            notes: cipher_data
214                .edit_request
215                .notes
216                .as_ref()
217                .map(|n| n.encrypt(ctx, cipher_key))
218                .transpose()?
219                .map(|n| n.to_string()),
220            fields: Some(
221                cipher_data
222                    .edit_request
223                    .fields
224                    .encrypt_composite(ctx, cipher_key)?
225                    .into_iter()
226                    .map(|f| f.into())
227                    .collect(),
228            ),
229            password_history: Some(
230                cipher_data
231                    .password_history
232                    .encrypt_composite(ctx, cipher_key)?
233                    .into_iter()
234                    .map(Into::into)
235                    .collect(),
236            ),
237            attachments: None,
238            attachments2: Some(
239                cipher_data
240                    .edit_request
241                    .attachments
242                    .encrypt_composite(ctx, cipher_key)?
243                    .into_iter()
244                    .map(|a| {
245                        Ok((
246                            a.id.clone().ok_or(CryptoError::MissingField("id"))?,
247                            a.into(),
248                        )) as Result<_, CryptoError>
249                    })
250                    .collect::<Result<_, _>>()?,
251            ),
252            login: cipher_data
253                .edit_request
254                .r#type
255                .as_login_view()
256                .map(|l| l.encrypt_composite(ctx, cipher_key))
257                .transpose()?
258                .map(|l| Box::new(l.into())),
259            card: cipher_data
260                .edit_request
261                .r#type
262                .as_card_view()
263                .map(|c| c.encrypt_composite(ctx, cipher_key))
264                .transpose()?
265                .map(|c| Box::new(c.into())),
266            identity: cipher_data
267                .edit_request
268                .r#type
269                .as_identity_view()
270                .map(|i| i.encrypt_composite(ctx, cipher_key))
271                .transpose()?
272                .map(|c| Box::new(c.into())),
273
274            secure_note: cipher_data
275                .edit_request
276                .r#type
277                .as_secure_note_view()
278                .map(|i| i.encrypt_composite(ctx, cipher_key))
279                .transpose()?
280                .map(|c| Box::new(c.into())),
281            ssh_key: cipher_data
282                .edit_request
283                .r#type
284                .as_ssh_key_view()
285                .map(|i| i.encrypt_composite(ctx, cipher_key))
286                .transpose()?
287                .map(|c| Box::new(c.into())),
288
289            last_known_revision_date: Some(cipher_data.edit_request.revision_date.to_rfc3339()),
290            archived_date: cipher_data
291                .edit_request
292                .archived_date
293                .map(|d| d.to_rfc3339()),
294            data: None,
295        };
296
297        Ok(cipher_request)
298    }
299}
300
301impl IdentifyKey<SymmetricKeyId> for CipherEditRequest {
302    fn key_identifier(&self) -> SymmetricKeyId {
303        match self.organization_id {
304            Some(organization_id) => SymmetricKeyId::Organization(organization_id),
305            None => SymmetricKeyId::User,
306        }
307    }
308}
309
310impl IdentifyKey<SymmetricKeyId> for CipherEditRequestInternal {
311    fn key_identifier(&self) -> SymmetricKeyId {
312        self.edit_request.key_identifier()
313    }
314}
315
316async fn edit_cipher<R: Repository<Cipher> + ?Sized>(
317    key_store: &KeyStore<KeyIds>,
318    api_client: &bitwarden_api_api::apis::ApiClient,
319    repository: &R,
320    encrypted_for: UserId,
321    request: CipherEditRequest,
322) -> Result<CipherView, EditCipherError> {
323    let cipher_id = request.id;
324
325    let original_cipher = repository
326        .get(cipher_id.to_string())
327        .await?
328        .ok_or(ItemNotFoundError)?;
329    let original_cipher_view: CipherView = key_store.decrypt(&original_cipher)?;
330
331    let request = CipherEditRequestInternal::new(request, &original_cipher_view);
332
333    let mut cipher_request = key_store.encrypt(request)?;
334    cipher_request.encrypted_for = Some(encrypted_for.into());
335
336    let response = api_client
337        .ciphers_api()
338        .put(cipher_id.into(), Some(cipher_request))
339        .await
340        .map_err(ApiError::from)?;
341
342    let cipher: Cipher = response.try_into()?;
343
344    debug_assert!(cipher.id.unwrap_or_default() == cipher_id);
345
346    repository
347        .set(cipher_id.to_string(), cipher.clone())
348        .await?;
349
350    Ok(key_store.decrypt(&cipher)?)
351}
352
353#[cfg_attr(feature = "wasm", wasm_bindgen)]
354impl CiphersClient {
355    /// Edit an existing [Cipher] and save it to the server.
356    pub async fn edit(
357        &self,
358        mut request: CipherEditRequest,
359    ) -> Result<CipherView, EditCipherError> {
360        let key_store = self.client.internal.get_key_store();
361        let config = self.client.internal.get_api_configurations().await;
362        let repository = self.get_repository()?;
363
364        let user_id = self
365            .client
366            .internal
367            .get_user_id()
368            .ok_or(NotAuthenticatedError)?;
369
370        // TODO: Once this flag is removed, the key generation logic should
371        // be moved closer to the actual encryption logic.
372        if request.key.is_none()
373            && self
374                .client
375                .internal
376                .get_flags()
377                .enable_cipher_key_encryption
378        {
379            let key = request.key_identifier();
380            request.generate_cipher_key(&mut key_store.context(), key)?;
381        }
382
383        edit_cipher(
384            key_store,
385            &config.api_client,
386            repository.as_ref(),
387            user_id,
388            request,
389        )
390        .await
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel};
397    use bitwarden_core::key_management::SymmetricKeyId;
398    use bitwarden_crypto::{KeyStore, PrimitiveEncryptable, SymmetricCryptoKey};
399    use bitwarden_test::MemoryRepository;
400    use chrono::TimeZone;
401
402    use super::*;
403    use crate::{
404        Cipher, CipherId, CipherRepromptType, CipherType, FieldType, Login, LoginView,
405        PasswordHistoryView,
406    };
407
408    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
409    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
410
411    fn generate_test_cipher() -> CipherView {
412        CipherView {
413            id: Some(TEST_CIPHER_ID.parse().unwrap()),
414            organization_id: None,
415            folder_id: None,
416            collection_ids: vec![],
417            key: None,
418            name: "Test Login".to_string(),
419            notes: None,
420            r#type: CipherType::Login,
421            login: Some(LoginView {
422                username: Some("[email protected]".to_string()),
423                password: Some("password123".to_string()),
424                password_revision_date: None,
425                uris: None,
426                totp: None,
427                autofill_on_page_load: None,
428                fido2_credentials: None,
429            }),
430            identity: None,
431            card: None,
432            secure_note: None,
433            ssh_key: None,
434            favorite: false,
435            reprompt: CipherRepromptType::None,
436            organization_use_totp: true,
437            edit: true,
438            permissions: None,
439            view_password: true,
440            local_data: None,
441            attachments: None,
442            fields: None,
443            password_history: None,
444            creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
445            deleted_date: None,
446            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
447            archived_date: None,
448        }
449    }
450
451    fn create_test_login_cipher(password: &str) -> CipherView {
452        let mut cipher_view = generate_test_cipher();
453        if let Some(ref mut login) = cipher_view.login {
454            login.password = Some(password.to_string());
455        }
456        cipher_view
457    }
458
459    async fn repository_add_cipher(
460        repository: &MemoryRepository<Cipher>,
461        store: &KeyStore<KeyIds>,
462        cipher_id: CipherId,
463        name: &str,
464    ) {
465        let cipher = {
466            let mut ctx = store.context();
467
468            Cipher {
469                id: Some(cipher_id),
470                organization_id: None,
471                folder_id: None,
472                collection_ids: vec![],
473                key: None,
474                name: name.encrypt(&mut ctx, SymmetricKeyId::User).unwrap(),
475                notes: None,
476                r#type: CipherType::Login,
477                login: Some(Login {
478                    username: Some("[email protected]")
479                        .map(|u| u.encrypt(&mut ctx, SymmetricKeyId::User))
480                        .transpose()
481                        .unwrap(),
482                    password: Some("password123")
483                        .map(|p| p.encrypt(&mut ctx, SymmetricKeyId::User))
484                        .transpose()
485                        .unwrap(),
486                    password_revision_date: None,
487                    uris: None,
488                    totp: None,
489                    autofill_on_page_load: None,
490                    fido2_credentials: None,
491                }),
492                identity: None,
493                card: None,
494                secure_note: None,
495                ssh_key: None,
496                favorite: false,
497                reprompt: CipherRepromptType::None,
498                organization_use_totp: true,
499                edit: true,
500                permissions: None,
501                view_password: true,
502                local_data: None,
503                attachments: None,
504                fields: None,
505                password_history: None,
506                creation_date: "2024-01-01T00:00:00Z".parse().unwrap(),
507                deleted_date: None,
508                revision_date: "2024-01-01T00:00:00Z".parse().unwrap(),
509                archived_date: None,
510                data: None,
511            }
512        };
513
514        repository.set(cipher_id.to_string(), cipher).await.unwrap();
515    }
516
517    #[tokio::test]
518    async fn test_edit_cipher() {
519        let store: KeyStore<KeyIds> = KeyStore::default();
520        #[allow(deprecated)]
521        let _ = store.context_mut().set_symmetric_key(
522            SymmetricKeyId::User,
523            SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
524        );
525
526        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
527
528        let api_client = ApiClient::new_mocked(move |mock| {
529            mock.ciphers_api
530                .expect_put()
531                .returning(move |_id, body| {
532                    let body = body.unwrap();
533                    Ok(CipherResponseModel {
534                        object: Some("cipher".to_string()),
535                        id: Some(cipher_id.into()),
536                        name: Some(body.name),
537                        r#type: body.r#type,
538                        organization_id: body
539                            .organization_id
540                            .as_ref()
541                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
542                        folder_id: body
543                            .folder_id
544                            .as_ref()
545                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
546                        favorite: body.favorite,
547                        reprompt: body.reprompt,
548                        key: body.key,
549                        notes: body.notes,
550                        view_password: Some(true),
551                        edit: Some(true),
552                        organization_use_totp: Some(true),
553                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
554                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
555                        deleted_date: None,
556                        login: body.login,
557                        card: body.card,
558                        identity: body.identity,
559                        secure_note: body.secure_note,
560                        ssh_key: body.ssh_key,
561                        fields: body.fields,
562                        password_history: body.password_history,
563                        attachments: None,
564                        permissions: None,
565                        data: None,
566                        archived_date: None,
567                    })
568                })
569                .once();
570        });
571
572        let repository = MemoryRepository::<Cipher>::default();
573        repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
574        let cipher_view = generate_test_cipher();
575
576        let request = cipher_view.try_into().unwrap();
577
578        let result = edit_cipher(
579            &store,
580            &api_client,
581            &repository,
582            TEST_USER_ID.parse().unwrap(),
583            request,
584        )
585        .await
586        .unwrap();
587
588        assert_eq!(result.id, Some(cipher_id));
589        assert_eq!(result.name, "Test Login");
590    }
591
592    #[tokio::test]
593    async fn test_edit_cipher_does_not_exist() {
594        let store: KeyStore<KeyIds> = KeyStore::default();
595
596        let repository = MemoryRepository::<Cipher>::default();
597
598        let cipher_view = generate_test_cipher();
599        let api_client = ApiClient::new_mocked(|_| {});
600
601        let request = cipher_view.try_into().unwrap();
602
603        let result = edit_cipher(
604            &store,
605            &api_client,
606            &repository,
607            TEST_USER_ID.parse().unwrap(),
608            request,
609        )
610        .await;
611
612        assert!(result.is_err());
613        assert!(matches!(
614            result.unwrap_err(),
615            EditCipherError::ItemNotFound(_)
616        ));
617    }
618
619    #[tokio::test]
620    async fn test_edit_cipher_http_error() {
621        let store: KeyStore<KeyIds> = KeyStore::default();
622        #[allow(deprecated)]
623        let _ = store.context_mut().set_symmetric_key(
624            SymmetricKeyId::User,
625            SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
626        );
627
628        let cipher_id: CipherId = "5faa9684-c793-4a2d-8a12-b33900187097".parse().unwrap();
629
630        let api_client = ApiClient::new_mocked(move |mock| {
631            mock.ciphers_api.expect_put().returning(move |_id, _body| {
632                Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
633                    "Simulated error",
634                )))
635            });
636        });
637
638        let repository = MemoryRepository::<Cipher>::default();
639        repository_add_cipher(&repository, &store, cipher_id, "old_name").await;
640        let cipher_view = generate_test_cipher();
641
642        let request = cipher_view.try_into().unwrap();
643
644        let result = edit_cipher(
645            &store,
646            &api_client,
647            &repository,
648            TEST_USER_ID.parse().unwrap(),
649            request,
650        )
651        .await;
652
653        assert!(result.is_err());
654        assert!(matches!(result.unwrap_err(), EditCipherError::Api(_)));
655    }
656
657    #[test]
658    fn test_password_history_on_password_change() {
659        let original_cipher = create_test_login_cipher("old_password");
660        let edit_request =
661            CipherEditRequest::try_from(create_test_login_cipher("new_password")).unwrap();
662
663        let start = Utc::now();
664        let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher);
665        let history = internal_req.password_history;
666        let end = Utc::now();
667
668        assert_eq!(history.len(), 1);
669        assert!(
670            history[0].last_used_date >= start && history[0].last_used_date <= end,
671            "last_used_date was not set properly"
672        );
673        assert_eq!(history[0].password, "old_password");
674    }
675
676    #[test]
677    fn test_password_history_on_unchanged_password() {
678        let original_cipher = create_test_login_cipher("same_password");
679        let edit_request =
680            CipherEditRequest::try_from(create_test_login_cipher("same_password")).unwrap();
681
682        let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher);
683        let password_history = internal_req.password_history;
684
685        assert!(password_history.is_empty());
686    }
687
688    #[test]
689    fn test_password_history_is_preserved() {
690        let mut original_cipher = create_test_login_cipher("same_password");
691        original_cipher.password_history = Some(
692            (0..4)
693                .map(|i| PasswordHistoryView {
694                    password: format!("old_password_{}", i),
695                    last_used_date: Utc.with_ymd_and_hms(2025, i + 1, i + 1, i, i, i).unwrap(),
696                })
697                .collect(),
698        );
699
700        let edit_request =
701            CipherEditRequest::try_from(create_test_login_cipher("same_password")).unwrap();
702        let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher);
703        let history = internal_req.password_history;
704
705        assert_eq!(history[0].password, "old_password_0");
706
707        assert_eq!(
708            history[0].last_used_date,
709            Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
710        );
711        assert_eq!(history[1].password, "old_password_1");
712        assert_eq!(
713            history[1].last_used_date,
714            Utc.with_ymd_and_hms(2025, 2, 2, 1, 1, 1).unwrap()
715        );
716        assert_eq!(history[2].password, "old_password_2");
717        assert_eq!(
718            history[2].last_used_date,
719            Utc.with_ymd_and_hms(2025, 3, 3, 2, 2, 2).unwrap()
720        );
721        assert_eq!(history[3].password, "old_password_3");
722        assert_eq!(
723            history[3].last_used_date,
724            Utc.with_ymd_and_hms(2025, 4, 4, 3, 3, 3).unwrap()
725        );
726    }
727
728    #[test]
729    fn test_password_history_with_hidden_fields() {
730        let mut original_cipher = create_test_login_cipher("password");
731        original_cipher.fields = Some(vec![FieldView {
732            name: Some("Secret Key".to_string()),
733            value: Some("old_secret_value".to_string()),
734            r#type: FieldType::Hidden,
735            linked_id: None,
736        }]);
737
738        let mut new_cipher = create_test_login_cipher("password");
739        new_cipher.fields = Some(vec![FieldView {
740            name: Some("Secret Key".to_string()),
741            value: Some("new_secret_value".to_string()),
742            r#type: FieldType::Hidden,
743            linked_id: None,
744        }]);
745
746        let edit_request = CipherEditRequest::try_from(new_cipher).unwrap();
747
748        let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher);
749        let history = internal_req.password_history;
750
751        assert_eq!(history.len(), 1);
752        assert_eq!(history[0].password, "Secret Key: old_secret_value");
753    }
754
755    #[test]
756    fn test_password_history_length_limit() {
757        let mut original_cipher = create_test_login_cipher("password");
758        original_cipher.password_history = Some(
759            (0..10)
760                .map(|i| PasswordHistoryView {
761                    password: format!("old_password_{}", i),
762                    last_used_date: Utc::now(),
763                })
764                .collect(),
765        );
766
767        // Create edit request with new password (no existing history)
768        let edit_request =
769            CipherEditRequest::try_from(create_test_login_cipher("new_password")).unwrap();
770
771        let internal_req = CipherEditRequestInternal::new(edit_request, &original_cipher);
772        let history = internal_req.password_history;
773
774        assert_eq!(history.len(), MAX_PASSWORD_HISTORY_ENTRIES);
775        // Most recent change (original password) should be first
776        assert_eq!(history[0].password, "password");
777
778        assert_eq!(history[1].password, "old_password_0");
779        assert_eq!(history[2].password, "old_password_1");
780        assert_eq!(history[3].password, "old_password_2");
781        assert_eq!(history[4].password, "old_password_3");
782    }
783}