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#[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 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#[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 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 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 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 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 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}