bitwarden_vault/cipher/cipher_client/
edit.rs

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