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