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