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