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