1use bitwarden_api_api::{
2 apis::ciphers_api::CiphersApi,
3 models::{CipherBulkShareRequestModel, CipherShareRequestModel},
4};
5use bitwarden_collections::collection::CollectionId;
6use bitwarden_core::{MissingFieldError, OrganizationId, require};
7use bitwarden_crypto::EncString;
8use bitwarden_state::repository::Repository;
9#[cfg(feature = "wasm")]
10use wasm_bindgen::prelude::wasm_bindgen;
11
12use crate::{
13 Cipher, CipherError, CipherId, CipherRepromptType, CipherView, CiphersClient,
14 EncryptionContext, VaultParseError,
15};
16
17async fn share_cipher(
20 api_client: &dyn CiphersApi,
21 repository: &dyn Repository<Cipher>,
22 encrypted_cipher: EncryptionContext,
23 collection_ids: Vec<CollectionId>,
24) -> Result<Cipher, CipherError> {
25 let cipher_id: uuid::Uuid = require!(encrypted_cipher.cipher.id).into();
26
27 let req = CipherShareRequestModel::new(
28 collection_ids
29 .iter()
30 .map(<CollectionId as ToString>::to_string)
31 .collect(),
32 encrypted_cipher.into(),
33 );
34
35 let response = api_client.put_share(cipher_id, Some(req)).await?;
36
37 let mut new_cipher: Cipher = response.try_into()?;
38 new_cipher.collection_ids = collection_ids;
39
40 repository
41 .set(cipher_id.to_string(), new_cipher.clone())
42 .await?;
43
44 Ok(new_cipher)
45}
46
47async fn share_ciphers_bulk(
50 api_client: &dyn CiphersApi,
51 repository: &dyn Repository<Cipher>,
52 encrypted_ciphers: Vec<EncryptionContext>,
53 collection_ids: Vec<CollectionId>,
54) -> Result<Vec<Cipher>, CipherError> {
55 let request = CipherBulkShareRequestModel::new(
56 collection_ids
57 .iter()
58 .map(<CollectionId as ToString>::to_string)
59 .collect(),
60 encrypted_ciphers
61 .into_iter()
62 .map(|ec| ec.try_into())
63 .collect::<Result<Vec<_>, _>>()?,
64 );
65
66 let response = api_client.put_share_many(Some(request)).await?;
67
68 let cipher_minis = response.data.unwrap_or_default();
69 let mut results = Vec::new();
70
71 for cipher_mini in cipher_minis {
72 let orig_cipher = repository
75 .get(cipher_mini.id.ok_or(MissingFieldError("id"))?.to_string())
76 .await?;
77
78 let cipher: Cipher = Cipher {
79 id: cipher_mini.id.map(CipherId::new),
80 organization_id: cipher_mini.organization_id.map(OrganizationId::new),
81 key: EncString::try_from_optional(cipher_mini.key)?,
82 name: require!(EncString::try_from_optional(cipher_mini.name)?),
83 notes: EncString::try_from_optional(cipher_mini.notes)?,
84 r#type: require!(cipher_mini.r#type).try_into()?,
85 login: cipher_mini.login.map(|l| (*l).try_into()).transpose()?,
86 identity: cipher_mini.identity.map(|i| (*i).try_into()).transpose()?,
87 card: cipher_mini.card.map(|c| (*c).try_into()).transpose()?,
88 secure_note: cipher_mini
89 .secure_note
90 .map(|s| (*s).try_into())
91 .transpose()?,
92 ssh_key: cipher_mini.ssh_key.map(|s| (*s).try_into()).transpose()?,
93 reprompt: cipher_mini
94 .reprompt
95 .map(|r| r.try_into())
96 .transpose()?
97 .unwrap_or(CipherRepromptType::None),
98 organization_use_totp: cipher_mini.organization_use_totp.unwrap_or(true),
99 attachments: cipher_mini
100 .attachments
101 .map(|a| a.into_iter().map(|a| a.try_into()).collect())
102 .transpose()?,
103 fields: cipher_mini
104 .fields
105 .map(|f| f.into_iter().map(|f| f.try_into()).collect())
106 .transpose()?,
107 password_history: cipher_mini
108 .password_history
109 .map(|p| p.into_iter().map(|p| p.try_into()).collect())
110 .transpose()?,
111 creation_date: require!(cipher_mini.creation_date)
112 .parse()
113 .map_err(Into::<VaultParseError>::into)?,
114 deleted_date: cipher_mini
115 .deleted_date
116 .map(|d| d.parse())
117 .transpose()
118 .map_err(Into::<VaultParseError>::into)?,
119 revision_date: require!(cipher_mini.revision_date)
120 .parse()
121 .map_err(Into::<VaultParseError>::into)?,
122 archived_date: orig_cipher
123 .as_ref()
124 .map(|c| c.archived_date)
125 .unwrap_or_default(),
126 edit: orig_cipher.as_ref().map(|c| c.edit).unwrap_or_default(),
127 favorite: orig_cipher.as_ref().map(|c| c.favorite).unwrap_or_default(),
128 folder_id: orig_cipher
129 .as_ref()
130 .map(|c| c.folder_id)
131 .unwrap_or_default(),
132 permissions: orig_cipher
133 .as_ref()
134 .map(|c| c.permissions)
135 .unwrap_or_default(),
136 view_password: orig_cipher
137 .as_ref()
138 .map(|c| c.view_password)
139 .unwrap_or_default(),
140 local_data: orig_cipher.map(|c| c.local_data).unwrap_or_default(),
141 collection_ids: collection_ids.clone(),
142 data: None,
143 };
144
145 repository
146 .set(require!(cipher.id).to_string(), cipher.clone())
147 .await?;
148 results.push(cipher)
149 }
150
151 Ok(results)
152}
153
154#[cfg_attr(feature = "wasm", wasm_bindgen)]
155impl CiphersClient {
156 fn update_organization_and_collections(
157 &self,
158 mut cipher_view: CipherView,
159 organization_id: OrganizationId,
160 collection_ids: Vec<CollectionId>,
161 ) -> Result<CipherView, CipherError> {
162 let organization_id = &organization_id;
163 if cipher_view.organization_id.is_some() {
164 return Err(CipherError::OrganizationAlreadySet);
165 }
166
167 cipher_view = self.move_to_organization(cipher_view, *organization_id)?;
168 cipher_view.collection_ids = collection_ids;
169 Ok(cipher_view)
170 }
171
172 pub async fn share_cipher(
174 &self,
175 mut cipher_view: CipherView,
176 organization_id: OrganizationId,
177 collection_ids: Vec<CollectionId>,
178 original_cipher: Option<Cipher>,
179 ) -> Result<Cipher, CipherError> {
180 cipher_view = self.update_organization_and_collections(
181 cipher_view,
182 organization_id,
183 collection_ids.clone(),
184 )?;
185
186 self.update_password_history(&mut cipher_view, original_cipher)
187 .await?;
188
189 let encrypted_cipher = self.encrypt(cipher_view)?;
190
191 let api_client = &self
192 .client
193 .internal
194 .get_api_configurations()
195 .await
196 .api_client;
197
198 share_cipher(
199 api_client.ciphers_api(),
200 &*self.get_repository()?,
201 encrypted_cipher,
202 collection_ids,
203 )
204 .await
205 }
206
207 async fn update_password_history(
208 &self,
209 cipher_view: &mut CipherView,
210 mut original_cipher: Option<Cipher>,
211 ) -> Result<(), CipherError> {
212 if let (Some(cipher_id), None) = (cipher_view.id, &original_cipher) {
213 original_cipher = self.get_repository()?.get(cipher_id.to_string()).await?;
214 }
215 if let Some(original_cipher_view) = original_cipher
216 .map(|cipher| self.decrypt(cipher))
217 .transpose()?
218 {
219 cipher_view.update_password_history(&original_cipher_view);
220 }
221 Ok(())
222 }
223
224 async fn prepare_encrypted_ciphers_for_bulk_share(
225 &self,
226 cipher_views: Vec<CipherView>,
227 organization_id: OrganizationId,
228 collection_ids: Vec<CollectionId>,
229 ) -> Result<Vec<EncryptionContext>, CipherError> {
230 let mut encrypted_ciphers: Vec<EncryptionContext> = Vec::new();
231 for mut cv in cipher_views {
232 cv = self.update_organization_and_collections(
233 cv,
234 organization_id,
235 collection_ids.clone(),
236 )?;
237 self.update_password_history(&mut cv, None).await?;
238 encrypted_ciphers.push(self.encrypt(cv)?);
239 }
240 Ok(encrypted_ciphers)
241 }
242
243 #[cfg(feature = "uniffi")]
244 pub async fn prepare_ciphers_for_bulk_share(
248 &self,
249 cipher_views: Vec<CipherView>,
250 organization_id: OrganizationId,
251 collection_ids: Vec<CollectionId>,
252 ) -> Result<Vec<EncryptionContext>, CipherError> {
253 self.prepare_encrypted_ciphers_for_bulk_share(cipher_views, organization_id, collection_ids)
254 .await
255 }
256
257 pub async fn share_ciphers_bulk(
260 &self,
261 cipher_views: Vec<CipherView>,
262 organization_id: OrganizationId,
263 collection_ids: Vec<CollectionId>,
264 ) -> Result<Vec<Cipher>, CipherError> {
265 let encrypted_ciphers = self
266 .prepare_encrypted_ciphers_for_bulk_share(
267 cipher_views,
268 organization_id,
269 collection_ids.clone(),
270 )
271 .await?;
272
273 let api_client = &self
274 .client
275 .internal
276 .get_api_configurations()
277 .await
278 .api_client;
279
280 share_ciphers_bulk(
281 api_client.ciphers_api(),
282 &*self.get_repository()?,
283 encrypted_ciphers,
284 collection_ids,
285 )
286 .await
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use bitwarden_api_api::{
293 apis::ApiClient,
294 models::{CipherMiniResponseModelListResponseModel, CipherResponseModel},
295 };
296 use bitwarden_core::{
297 Client,
298 client::test_accounts::test_bitwarden_com_account,
299 key_management::{
300 MasterPasswordUnlockData, account_cryptographic_state::WrappedAccountCryptographicState,
301 },
302 };
303 use bitwarden_test::{MemoryRepository, start_api_mock};
304 use wiremock::{
305 Mock, ResponseTemplate,
306 matchers::{method, path},
307 };
308
309 use super::*;
310 use crate::{CipherRepromptType, CipherType, LoginView, VaultClientExt};
311
312 const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
313 const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8";
314 const TEST_COLLECTION_ID_1: &str = "c1111111-1111-1111-1111-111111111111";
315 const TEST_COLLECTION_ID_2: &str = "c2222222-2222-2222-2222-222222222222";
316
317 fn test_cipher_view_without_org() -> CipherView {
318 CipherView {
319 r#type: CipherType::Login,
320 login: Some(LoginView {
321 username: Some("[email protected]".to_string()),
322 password: Some("password123".to_string()),
323 password_revision_date: None,
324 uris: None,
325 totp: None,
326 autofill_on_page_load: None,
327 fido2_credentials: None,
328 }),
329 id: Some(TEST_CIPHER_ID.parse().unwrap()),
330 organization_id: None,
331 folder_id: None,
332 collection_ids: vec![],
333 key: None,
334 name: "My test login".to_string(),
335 notes: Some("Test notes".to_string()),
336 identity: None,
337 card: None,
338 secure_note: None,
339 ssh_key: None,
340 favorite: false,
341 reprompt: CipherRepromptType::None,
342 organization_use_totp: true,
343 edit: true,
344 permissions: None,
345 view_password: true,
346 local_data: None,
347 attachments: None,
348 attachment_decryption_failures: None,
349 fields: None,
350 password_history: None,
351 creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
352 deleted_date: None,
353 revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
354 archived_date: None,
355 }
356 }
357
358 #[tokio::test]
359 async fn test_move_to_collections_success() {
360 let client = Client::init_test_account(test_bitwarden_com_account()).await;
361
362 let cipher_client = client.vault().ciphers();
363 let cipher_view = test_cipher_view_without_org();
364 let organization_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
365 let collection_ids: Vec<CollectionId> = vec![
366 TEST_COLLECTION_ID_1.parse().unwrap(),
367 TEST_COLLECTION_ID_2.parse().unwrap(),
368 ];
369
370 let result = cipher_client
371 .update_organization_and_collections(
372 cipher_view,
373 organization_id,
374 collection_ids.clone(),
375 )
376 .unwrap();
377
378 assert_eq!(result.organization_id, Some(organization_id));
379 assert_eq!(result.collection_ids, collection_ids);
380 }
381
382 #[tokio::test]
383 async fn test_move_to_collections_already_in_org() {
384 let client = Client::init_test_account(test_bitwarden_com_account()).await;
385
386 let cipher_client = client.vault().ciphers();
387 let mut cipher_view = test_cipher_view_without_org();
388 cipher_view.organization_id = Some(TEST_ORG_ID.parse().unwrap());
389
390 let organization_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
391 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
392
393 let result = cipher_client.update_organization_and_collections(
394 cipher_view,
395 organization_id,
396 collection_ids,
397 );
398
399 assert!(result.is_err());
400 assert!(matches!(
401 result.unwrap_err(),
402 CipherError::OrganizationAlreadySet
403 ));
404 }
405
406 #[tokio::test]
407 async fn test_share_ciphers_bulk_already_in_org() {
408 let client = Client::init_test_account(test_bitwarden_com_account()).await;
409
410 let cipher_client = client.vault().ciphers();
411 let mut cipher_view = test_cipher_view_without_org();
412 cipher_view.organization_id = Some(TEST_ORG_ID.parse().unwrap());
413
414 let organization_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
415 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
416
417 let result = cipher_client
418 .share_ciphers_bulk(vec![cipher_view], organization_id, collection_ids)
419 .await;
420
421 assert!(result.is_err());
422 assert!(matches!(
423 result.unwrap_err(),
424 CipherError::OrganizationAlreadySet
425 ));
426 }
427
428 #[tokio::test]
429 async fn test_move_to_collections_with_attachment_without_key_fails() {
430 let client = Client::init_test_account(test_bitwarden_com_account()).await;
431
432 let cipher_client = client.vault().ciphers();
433 let mut cipher_view = test_cipher_view_without_org();
434
435 cipher_view.attachments = Some(vec![crate::AttachmentView {
437 id: Some("attachment-456".to_string()),
438 url: Some("https://example.com/attachment".to_string()),
439 size: Some("2048".to_string()),
440 size_name: Some("2 KB".to_string()),
441 file_name: Some("test2.txt".to_string()),
442 key: None, #[cfg(feature = "wasm")]
444 decrypted_key: None,
445 }]);
446
447 let organization_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
448 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
449
450 let result = cipher_client.update_organization_and_collections(
451 cipher_view,
452 organization_id,
453 collection_ids,
454 );
455
456 assert!(result.is_err());
458 assert!(matches!(
459 result.unwrap_err(),
460 CipherError::AttachmentsWithoutKeys
461 ));
462 }
463
464 #[tokio::test]
465 async fn test_share_ciphers_bulk_multiple_validation() {
466 let client = Client::init_test_account(test_bitwarden_com_account()).await;
467
468 let repository = MemoryRepository::<Cipher>::default();
470 client
471 .platform()
472 .state()
473 .register_client_managed(std::sync::Arc::new(repository));
474
475 let cipher_client = client.vault().ciphers();
476
477 let cipher_view_1 = test_cipher_view_without_org();
479 let mut cipher_view_2 = test_cipher_view_without_org();
480 cipher_view_2.organization_id = Some(TEST_ORG_ID.parse().unwrap());
481
482 let encrypted_1 = cipher_client.encrypt(cipher_view_1.clone()).unwrap();
484 let repository = cipher_client.get_repository().unwrap();
485 repository
486 .set(TEST_CIPHER_ID.to_string(), encrypted_1.cipher.clone())
487 .await
488 .unwrap();
489
490 let organization_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
491 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
492
493 let result = cipher_client
495 .share_ciphers_bulk(
496 vec![cipher_view_1, cipher_view_2],
497 organization_id,
498 collection_ids,
499 )
500 .await;
501
502 assert!(result.is_err());
503 assert!(matches!(
504 result.unwrap_err(),
505 CipherError::OrganizationAlreadySet
506 ));
507 }
508
509 fn create_encryption_context() -> EncryptionContext {
510 use bitwarden_core::UserId;
511
512 use crate::cipher::Login;
513
514 let cipher = Cipher {
516 r#type: CipherType::Login,
517 login: Some(Login {
518 username: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
519 password: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
520 password_revision_date: None,
521 uris: None,
522 totp: None,
523 autofill_on_page_load: None,
524 fido2_credentials: None,
525 }),
526 id: Some(TEST_CIPHER_ID.parse().unwrap()),
527 organization_id: Some(TEST_ORG_ID.parse().unwrap()),
528 folder_id: None,
529 collection_ids: vec![TEST_COLLECTION_ID_1.parse().unwrap()],
530 key: None,
531 name: "2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap(),
532 notes: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
533 identity: None,
534 card: None,
535 secure_note: None,
536 ssh_key: None,
537 favorite: false,
538 reprompt: CipherRepromptType::None,
539 organization_use_totp: true,
540 edit: true,
541 permissions: None,
542 view_password: true,
543 local_data: None,
544 attachments: None,
545 fields: None,
546 password_history: None,
547 creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
548 deleted_date: None,
549 revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
550 archived_date: None,
551 data: None,
552 };
553
554 let user_id: UserId = "00000000-0000-0000-0000-000000000000".parse().unwrap();
556
557 EncryptionContext {
558 cipher,
559 encrypted_for: user_id,
560 }
561 }
562
563 #[tokio::test]
564 async fn test_share_cipher_api_success() {
565 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
566 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
567 let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
568
569 let api_client = ApiClient::new_mocked(move |mock| {
570 mock.ciphers_api.expect_put_share().returning(move |_id, _body| {
571 Ok(CipherResponseModel {
572 object: Some("cipher".to_string()),
573 id: Some(cipher_id.into()),
574 organization_id: Some(org_id.into()),
575 r#type: Some(bitwarden_api_api::models::CipherType::Login),
576 name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
577 notes: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
578 login: Some(Box::new(bitwarden_api_api::models::CipherLoginModel {
579 username: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
580 password: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
581 ..Default::default()
582 })),
583 reprompt: Some(bitwarden_api_api::models::CipherRepromptType::None),
584 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
585 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
586 edit: Some(true),
587 view_password: Some(true),
588 organization_use_totp: Some(true),
589 favorite: Some(false),
590 ..Default::default()
591 })
592 });
593 });
594
595 let repository = MemoryRepository::<Cipher>::default();
596 let encryption_context = create_encryption_context();
597 let collection_ids: Vec<CollectionId> = vec![collection_id];
598
599 let result = share_cipher(
600 api_client.ciphers_api(),
601 &repository,
602 encryption_context,
603 collection_ids.clone(),
604 )
605 .await;
606
607 assert!(result.is_ok());
608 let shared_cipher = result.unwrap();
609
610 let stored_cipher = repository
612 .get(TEST_CIPHER_ID.to_string())
613 .await
614 .unwrap()
615 .expect("Cipher should be stored");
616
617 assert_eq!(stored_cipher.id, shared_cipher.id);
618 assert_eq!(
619 stored_cipher
620 .organization_id
621 .as_ref()
622 .map(ToString::to_string),
623 Some(TEST_ORG_ID.to_string())
624 );
625 assert_eq!(stored_cipher.collection_ids, collection_ids);
626 }
627
628 #[tokio::test]
629 async fn test_share_cipher_api_handles_404() {
630 let api_client = ApiClient::new_mocked(|mock| {
631 mock.ciphers_api.expect_put_share().returning(|_id, _body| {
632 Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
633 "Not found",
634 )))
635 });
636 });
637
638 let repository = MemoryRepository::<Cipher>::default();
639 let encryption_context = create_encryption_context();
640 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
641
642 let result = share_cipher(
643 api_client.ciphers_api(),
644 &repository,
645 encryption_context,
646 collection_ids,
647 )
648 .await;
649
650 assert!(result.is_err());
651 }
652
653 #[tokio::test]
654 async fn test_share_ciphers_bulk_api_success() {
655 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
656 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
657
658 let api_client = ApiClient::new_mocked(move |mock| {
659 mock.ciphers_api.expect_put_share_many().returning(move |_body| {
660 Ok(CipherMiniResponseModelListResponseModel {
661 object: Some("list".to_string()),
662 data: Some(vec![bitwarden_api_api::models::CipherMiniResponseModel {
663 object: Some("cipherMini".to_string()),
664 id: Some(cipher_id.into()),
665 organization_id: Some(org_id.into()),
666 r#type: Some(bitwarden_api_api::models::CipherType::Login),
667 name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
668 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
669 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
670 ..Default::default()
671 }]),
672 continuation_token: None,
673 })
674 });
675 });
676
677 let repository = MemoryRepository::<Cipher>::default();
678
679 let original_cipher = Cipher {
681 r#type: CipherType::Login,
682 login: Some(crate::cipher::Login {
683 username: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
684 password: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
685 password_revision_date: None,
686 uris: None,
687 totp: None,
688 autofill_on_page_load: None,
689 fido2_credentials: None,
690 }),
691 id: Some(TEST_CIPHER_ID.parse().unwrap()),
692 organization_id: None,
693 folder_id: None,
694 collection_ids: vec![],
695 key: None,
696 name: "2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap(),
697 notes: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
698 identity: None,
699 card: None,
700 secure_note: None,
701 ssh_key: None,
702 favorite: true,
703 reprompt: CipherRepromptType::None,
704 organization_use_totp: true,
705 edit: true,
706 permissions: None,
707 view_password: true,
708 local_data: None,
709 attachments: None,
710 fields: None,
711 password_history: None,
712 creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
713 deleted_date: None,
714 revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
715 archived_date: None,
716 data: None,
717 };
718
719 repository
720 .set(TEST_CIPHER_ID.to_string(), original_cipher)
721 .await
722 .unwrap();
723
724 let encryption_context = create_encryption_context();
725 let collection_ids: Vec<CollectionId> = vec![
726 TEST_COLLECTION_ID_1.parse().unwrap(),
727 TEST_COLLECTION_ID_2.parse().unwrap(),
728 ];
729
730 let result = share_ciphers_bulk(
731 api_client.ciphers_api(),
732 &repository,
733 vec![encryption_context],
734 collection_ids.clone(),
735 )
736 .await;
737
738 assert!(result.is_ok());
739 let shared_ciphers = result.unwrap();
740 assert_eq!(shared_ciphers.len(), 1);
741
742 let shared_cipher = &shared_ciphers[0];
743 assert_eq!(
744 shared_cipher
745 .organization_id
746 .as_ref()
747 .map(ToString::to_string),
748 Some(TEST_ORG_ID.to_string())
749 );
750 assert_eq!(shared_cipher.collection_ids, collection_ids);
751
752 let stored_cipher = repository
754 .get(TEST_CIPHER_ID.to_string())
755 .await
756 .unwrap()
757 .expect("Cipher should be stored");
758
759 assert_eq!(stored_cipher.id, shared_cipher.id);
760 assert!(stored_cipher.favorite); }
762
763 #[tokio::test]
764 async fn test_share_ciphers_bulk_api_handles_error() {
765 let api_client = ApiClient::new_mocked(|mock| {
766 mock.ciphers_api.expect_put_share_many().returning(|_body| {
767 Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
768 "Server error",
769 )))
770 });
771 });
772
773 let repository = MemoryRepository::<Cipher>::default();
774 let encryption_context = create_encryption_context();
775 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
776
777 let result = share_ciphers_bulk(
778 api_client.ciphers_api(),
779 &repository,
780 vec![encryption_context],
781 collection_ids,
782 )
783 .await;
784
785 assert!(result.is_err());
786 }
787
788 async fn make_test_client_with_wiremock(mock_server: &wiremock::MockServer) -> Client {
789 use bitwarden_core::{
790 ClientSettings, DeviceType, UserId,
791 key_management::crypto::{
792 InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest,
793 },
794 };
795 use bitwarden_crypto::{EncString, Kdf};
796
797 let settings = ClientSettings {
798 identity_url: format!("http://{}", mock_server.address()),
799 api_url: format!("http://{}", mock_server.address()),
800 user_agent: "Bitwarden Test".into(),
801 device_type: DeviceType::SDK,
802 device_identifier: None,
803 bitwarden_client_version: None,
804 bitwarden_package_type: None,
805 };
806
807 let client = Client::new(Some(settings));
808
809 client
810 .internal
811 .load_flags(std::collections::HashMap::from([(
812 "enableCipherKeyEncryption".to_owned(),
813 true,
814 )]));
815
816 let user_request = InitUserCryptoRequest {
817 user_id: Some(UserId::new(uuid::uuid!("060000fb-0922-4dd3-b170-6e15cb5df8c8"))),
818 kdf_params: Kdf::PBKDF2 {
819 iterations: 600_000.try_into().unwrap(),
820 },
821 email: "[email protected]".to_owned(),
822 account_cryptographic_state: WrappedAccountCryptographicState::V1 {
823 private_key: "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=".parse::<EncString>().unwrap(),
824 },
825 method: InitUserCryptoMethod::MasterPasswordUnlock {
826 password: "asdfasdfasdf".to_owned(),
827 master_password_unlock: MasterPasswordUnlockData {
828 kdf: Kdf::PBKDF2 {
829 iterations: 600_000.try_into().unwrap(),
830 },
831 master_key_wrapped_user_key: "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=".parse().unwrap(),
832 salt: "[email protected]".to_owned(),
833 },
834 },
835 };
836
837 let org_request = InitOrgCryptoRequest {
838 organization_keys: std::collections::HashMap::from([(
839 TEST_ORG_ID.parse().unwrap(),
840 "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==".parse().unwrap()
841 )])
842 };
843
844 client
845 .crypto()
846 .initialize_user_crypto(user_request)
847 .await
848 .unwrap();
849 client
850 .crypto()
851 .initialize_org_crypto(org_request)
852 .await
853 .unwrap();
854
855 client
856 }
857
858 #[tokio::test]
859 async fn test_share_cipher_with_password_history() {
860 use bitwarden_test::start_api_mock;
861 use wiremock::{
862 Mock, ResponseTemplate,
863 matchers::{method, path_regex},
864 };
865 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
866 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
867 let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
868
869 let mut cipher_view = test_cipher_view_without_org();
870 if let Some(ref mut login) = cipher_view.login {
871 login.password = Some("original_password_123".to_string());
872 }
873
874 let mock = Mock::given(method("PUT"))
876 .and(path_regex(r"/ciphers/[a-f0-9-]+/share"))
877 .and(wiremock::matchers::body_string_contains("passwordHistory"))
878 .respond_with(move |req: &wiremock::Request| {
879 let body_bytes = req.body.as_slice();
880 let request_body: bitwarden_api_api::models::CipherShareRequestModel =
881 serde_json::from_slice(body_bytes).expect("Failed to parse request body");
882
883 let response = CipherResponseModel {
885 object: Some("cipher".to_string()),
886 id: Some(cipher_id.into()),
887 organization_id: Some(
888 request_body
889 .cipher
890 .organization_id
891 .unwrap()
892 .parse()
893 .unwrap(),
894 ),
895 r#type: request_body.cipher.r#type,
896 name: Some(request_body.cipher.name),
897 notes: request_body.cipher.notes,
898 login: request_body.cipher.login,
899 reprompt: request_body.cipher.reprompt,
900 password_history: request_body.cipher.password_history,
901 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
902 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
903 edit: Some(true),
904 view_password: Some(true),
905 organization_use_totp: Some(true),
906 favorite: request_body.cipher.favorite,
907 fields: request_body.cipher.fields,
908 key: request_body.cipher.key,
909 ..Default::default()
910 };
911
912 ResponseTemplate::new(200).set_body_json(&response)
913 });
914
915 let (mock_server, _config) = start_api_mock(vec![mock]).await;
917 let client = make_test_client_with_wiremock(&mock_server).await;
918 let repository = std::sync::Arc::new(MemoryRepository::<Cipher>::default());
919 let cipher_client = client.vault().ciphers();
920 let encrypted_original = cipher_client.encrypt(cipher_view.clone()).unwrap();
921 repository
922 .set(
923 TEST_CIPHER_ID.to_string(),
924 encrypted_original.cipher.clone(),
925 )
926 .await
927 .unwrap();
928
929 client
930 .platform()
931 .state()
932 .register_client_managed(repository.clone());
933
934 if let Some(ref mut login) = cipher_view.login {
936 login.password = Some("new_password_456".to_string());
937 }
938
939 let result = cipher_client
940 .share_cipher(
941 cipher_view.clone(),
942 org_id,
943 vec![collection_id],
944 Some(encrypted_original.cipher),
945 )
946 .await;
947
948 let shared_cipher = result.unwrap();
949 assert_eq!(shared_cipher.organization_id, Some(org_id));
950 let decrypted_view = cipher_client.decrypt(shared_cipher.clone()).unwrap();
951 let history = decrypted_view.password_history.unwrap();
952 assert_eq!(
953 history.len(),
954 1,
955 "Password history should have 1 entry for the changed password"
956 );
957 assert_eq!(
958 history[0].password, "original_password_123",
959 "Password history should contain the original password"
960 );
961 assert_eq!(
962 decrypted_view.login.as_ref().unwrap().password,
963 Some("new_password_456".to_string()),
964 "New password should be set"
965 );
966 }
967
968 #[tokio::test]
969 async fn test_share_ciphers_bulk_with_password_history() {
970 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
971 let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
972
973 let mut cipher_view1 = test_cipher_view_without_org();
974 cipher_view1.id = Some(TEST_CIPHER_ID.parse().unwrap());
975 if let Some(ref mut login) = cipher_view1.login {
976 login.password = Some("original_password_1".to_string());
977 }
978
979 let mut cipher_view2 = test_cipher_view_without_org();
980 cipher_view2.id = Some("11111111-2222-3333-4444-555555555555".parse().unwrap());
981 if let Some(ref mut login) = cipher_view2.login {
982 login.password = Some("original_password_2".to_string());
983 }
984
985 let mock = Mock::given(method("PUT"))
987 .and(path("/ciphers/share"))
988 .and(wiremock::matchers::body_string_contains("passwordHistory"))
989 .respond_with(move |req: &wiremock::Request| {
990 let body_bytes = req.body.as_slice();
991 let request_body: bitwarden_api_api::models::CipherBulkShareRequestModel =
992 serde_json::from_slice(body_bytes).expect("Failed to parse request body");
993
994 let ciphers: Vec<_> = request_body
996 .ciphers
997 .into_iter()
998 .map(
999 |cipher| bitwarden_api_api::models::CipherMiniResponseModel {
1000 object: Some("cipherMini".to_string()),
1001 id: Some(cipher.id),
1002 organization_id: cipher.organization_id.and_then(|id| id.parse().ok()),
1003 r#type: cipher.r#type,
1004 name: Some(cipher.name),
1005 notes: cipher.notes,
1006 login: cipher.login,
1007 reprompt: cipher.reprompt,
1008 password_history: cipher.password_history,
1009 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
1010 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
1011 organization_use_totp: Some(true),
1012 fields: cipher.fields,
1013 key: cipher.key,
1014 ..Default::default()
1015 },
1016 )
1017 .collect();
1018
1019 let response =
1020 bitwarden_api_api::models::CipherMiniResponseModelListResponseModel {
1021 object: Some("list".to_string()),
1022 data: Some(ciphers),
1023 continuation_token: None,
1024 };
1025
1026 ResponseTemplate::new(200).set_body_json(&response)
1027 });
1028
1029 let (mock_server, _config) = start_api_mock(vec![mock]).await;
1031 let client = make_test_client_with_wiremock(&mock_server).await;
1032 let repository = std::sync::Arc::new(MemoryRepository::<Cipher>::default());
1033 let cipher_client = client.vault().ciphers();
1034
1035 let encrypted_original1 = cipher_client.encrypt(cipher_view1.clone()).unwrap();
1036 repository
1037 .set(
1038 encrypted_original1.cipher.id.unwrap().to_string(),
1039 encrypted_original1.cipher.clone(),
1040 )
1041 .await
1042 .unwrap();
1043
1044 let encrypted_original2 = cipher_client.encrypt(cipher_view2.clone()).unwrap();
1045 repository
1046 .set(
1047 encrypted_original2.cipher.id.unwrap().to_string(),
1048 encrypted_original2.cipher.clone(),
1049 )
1050 .await
1051 .unwrap();
1052
1053 client
1054 .platform()
1055 .state()
1056 .register_client_managed(repository.clone());
1057
1058 if let Some(ref mut login) = cipher_view1.login {
1060 login.password = Some("new_password_1".to_string());
1061 }
1062 if let Some(ref mut login) = cipher_view2.login {
1063 login.password = Some("new_password_2".to_string());
1064 }
1065
1066 let result = cipher_client
1067 .share_ciphers_bulk(
1068 vec![cipher_view1, cipher_view2],
1069 org_id,
1070 vec![collection_id],
1071 )
1072 .await;
1073
1074 let shared_ciphers = result.unwrap();
1075 assert_eq!(shared_ciphers.len(), 2);
1076
1077 let decrypted_view1 = cipher_client.decrypt(shared_ciphers[0].clone()).unwrap();
1078 assert_eq!(
1079 decrypted_view1.password_history.unwrap()[0].password,
1080 "original_password_1"
1081 );
1082 assert_eq!(
1083 decrypted_view1.login.unwrap().password,
1084 Some("new_password_1".to_string())
1085 );
1086
1087 let decrypted_view2 = cipher_client.decrypt(shared_ciphers[1].clone()).unwrap();
1088 assert_eq!(
1089 decrypted_view2.password_history.unwrap()[0].password,
1090 "original_password_2"
1091 );
1092 assert_eq!(
1093 decrypted_view2.login.unwrap().password,
1094 Some("new_password_2".to_string())
1095 );
1096 }
1097}