bitwarden_vault/cipher/cipher_client/
edit.rs

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