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