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
30const 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#[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 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#[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 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 current_login.password_revision_date = original_login.password_revision_date;
189 vec![]
190 } else {
191 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 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 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 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 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}