Skip to main content

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