1use std::collections::HashSet;
2
3use bitwarden_api_api::{
4 apis::ciphers_api::CiphersApi,
5 models::{CipherBulkShareRequestModel, CipherShareRequestModel},
6};
7use bitwarden_collections::collection::CollectionId;
8use bitwarden_core::{MissingFieldError, OrganizationId, require};
9use bitwarden_state::repository::Repository;
10#[cfg(feature = "wasm")]
11use wasm_bindgen::prelude::wasm_bindgen;
12
13use crate::{
14 Cipher, CipherError, CipherId, CipherView, CiphersClient, EncryptionContext,
15 cipher::cipher::PartialCipher,
16};
17
18async fn share_cipher(
21 api_client: &dyn CiphersApi,
22 repository: &dyn Repository<Cipher>,
23 encrypted_cipher: EncryptionContext,
24 collection_ids: Vec<CollectionId>,
25) -> Result<Cipher, CipherError> {
26 let cipher_id = require!(encrypted_cipher.cipher.id);
27 let cipher_uuid: uuid::Uuid = cipher_id.into();
28
29 let req = CipherShareRequestModel::new(
30 collection_ids
31 .iter()
32 .map(<CollectionId as ToString>::to_string)
33 .collect(),
34 encrypted_cipher.into(),
35 );
36
37 let response = api_client.put_share(cipher_uuid, Some(req)).await?;
38
39 let mut new_cipher: Cipher = response.merge_with_cipher(None)?;
40 new_cipher.collection_ids = collection_ids;
41
42 repository.set(cipher_id, new_cipher.clone()).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 mut withheld_ids: HashSet<CipherId> = encrypted_ciphers
58 .iter()
59 .map(|ec| ec.cipher.id.ok_or(MissingFieldError("id")))
60 .collect::<Result<_, _>>()?;
61
62 let request = CipherBulkShareRequestModel::new(
63 collection_ids
64 .iter()
65 .map(<CollectionId as ToString>::to_string)
66 .collect(),
67 encrypted_ciphers
68 .into_iter()
69 .map(|ec| ec.try_into())
70 .collect::<Result<Vec<_>, _>>()?,
71 );
72
73 let response = api_client.put_share_many(Some(request)).await?;
74
75 let cipher_minis = response.data.unwrap_or_default();
76 let mut results = Vec::new();
77
78 for cipher_mini in cipher_minis {
79 let cipher_id = CipherId::new(cipher_mini.id.ok_or(MissingFieldError("id"))?);
82 let orig_cipher = repository.get(cipher_id).await?;
83
84 let mut cipher: Cipher = cipher_mini.merge_with_cipher(orig_cipher)?;
85 cipher.collection_ids = collection_ids.clone();
86
87 repository.set(require!(cipher.id), cipher.clone()).await?;
88 withheld_ids.remove(&cipher_id);
89 results.push(cipher)
90 }
91
92 if !withheld_ids.is_empty() {
98 repository
99 .remove_bulk(withheld_ids.into_iter().collect())
100 .await?;
101 }
102
103 Ok(results)
104}
105
106#[allow(deprecated)]
107#[cfg_attr(feature = "wasm", wasm_bindgen)]
108impl CiphersClient {
109 fn update_organization_and_collections(
110 &self,
111 mut cipher_view: CipherView,
112 organization_id: OrganizationId,
113 collection_ids: Vec<CollectionId>,
114 ) -> Result<CipherView, CipherError> {
115 let organization_id = &organization_id;
116 if cipher_view.organization_id.is_some() {
117 return Err(CipherError::OrganizationAlreadySet);
118 }
119
120 cipher_view = self.move_to_organization(cipher_view, *organization_id)?;
121 cipher_view.collection_ids = collection_ids;
122 Ok(cipher_view)
123 }
124
125 pub async fn share_cipher(
127 &self,
128 mut cipher_view: CipherView,
129 organization_id: OrganizationId,
130 collection_ids: Vec<CollectionId>,
131 original_cipher_view: Option<CipherView>,
132 ) -> Result<CipherView, CipherError> {
133 cipher_view = self.update_organization_and_collections(
134 cipher_view,
135 organization_id,
136 collection_ids.clone(),
137 )?;
138
139 self.update_password_history(&mut cipher_view, original_cipher_view)
140 .await?;
141
142 let encrypted_cipher = self.encrypt(cipher_view).await?;
143
144 let api_client = &self.client.internal.get_api_configurations().api_client;
145
146 let result_cipher = share_cipher(
147 api_client.ciphers_api(),
148 &*self.get_repository()?,
149 encrypted_cipher,
150 collection_ids,
151 )
152 .await?;
153 Ok(self.decrypt(result_cipher).await?)
154 }
155
156 async fn update_password_history(
157 &self,
158 cipher_view: &mut CipherView,
159 mut original_cipher_view: Option<CipherView>,
160 ) -> Result<(), CipherError> {
161 if let Some(cipher_id) = cipher_view.id
162 && original_cipher_view.is_none()
163 && let Some(cipher) = self.get_repository()?.get(cipher_id).await?
164 {
165 original_cipher_view = Some(self.decrypt(cipher).await?);
166 }
167 if let Some(original_cipher_view) = original_cipher_view {
168 cipher_view.update_password_history(&original_cipher_view);
169 }
170 Ok(())
171 }
172
173 async fn prepare_encrypted_ciphers_for_bulk_share(
174 &self,
175 cipher_views: Vec<CipherView>,
176 organization_id: OrganizationId,
177 collection_ids: Vec<CollectionId>,
178 ) -> Result<Vec<EncryptionContext>, CipherError> {
179 let mut encrypted_ciphers: Vec<EncryptionContext> = Vec::new();
180 for mut cv in cipher_views {
181 cv = self.update_organization_and_collections(
182 cv,
183 organization_id,
184 collection_ids.clone(),
185 )?;
186 self.update_password_history(&mut cv, None).await?;
187 encrypted_ciphers.push(self.encrypt(cv).await?);
188 }
189 Ok(encrypted_ciphers)
190 }
191
192 #[cfg(feature = "uniffi")]
193 pub async fn prepare_ciphers_for_bulk_share(
197 &self,
198 cipher_views: Vec<CipherView>,
199 organization_id: OrganizationId,
200 collection_ids: Vec<CollectionId>,
201 ) -> Result<Vec<EncryptionContext>, CipherError> {
202 self.prepare_encrypted_ciphers_for_bulk_share(cipher_views, organization_id, collection_ids)
203 .await
204 }
205
206 pub async fn share_ciphers_bulk(
209 &self,
210 cipher_views: Vec<CipherView>,
211 organization_id: OrganizationId,
212 collection_ids: Vec<CollectionId>,
213 ) -> Result<Vec<CipherView>, CipherError> {
214 let encrypted_ciphers = self
215 .prepare_encrypted_ciphers_for_bulk_share(
216 cipher_views,
217 organization_id,
218 collection_ids.clone(),
219 )
220 .await?;
221
222 let api_client = &self.client.internal.get_api_configurations().api_client;
223
224 let result_ciphers = share_ciphers_bulk(
225 api_client.ciphers_api(),
226 &*self.get_repository()?,
227 encrypted_ciphers,
228 collection_ids,
229 )
230 .await?;
231
232 Ok(
233 futures::future::try_join_all(result_ciphers.into_iter().map(|c| self.decrypt(c)))
234 .await?,
235 )
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use bitwarden_api_api::{
242 apis::ApiClient,
243 models::{CipherMiniResponseModelListResponseModel, CipherResponseModel},
244 };
245 use bitwarden_core::{
246 Client,
247 client::test_accounts::test_bitwarden_com_account,
248 key_management::{
249 MasterPasswordUnlockData, account_cryptographic_state::WrappedAccountCryptographicState,
250 },
251 };
252 use bitwarden_test::{MemoryRepository, start_api_mock};
253 use wiremock::{
254 Mock, ResponseTemplate,
255 matchers::{method, path},
256 };
257
258 use super::*;
259 use crate::{CipherRepromptType, CipherType, LoginView, VaultClientExt};
260
261 const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
262 const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8";
263 const TEST_COLLECTION_ID_1: &str = "c1111111-1111-1111-1111-111111111111";
264 const TEST_COLLECTION_ID_2: &str = "c2222222-2222-2222-2222-222222222222";
265
266 fn test_cipher_view_without_org() -> CipherView {
267 CipherView {
268 partial: false,
269 r#type: CipherType::Login,
270 login: Some(LoginView {
271 username: Some("[email protected]".to_string()),
272 password: Some("password123".to_string()),
273 password_revision_date: None,
274 uris: None,
275 totp: None,
276 autofill_on_page_load: None,
277 fido2_credentials: None,
278 }),
279 id: Some(TEST_CIPHER_ID.parse().unwrap()),
280 organization_id: None,
281 folder_id: None,
282 collection_ids: vec![],
283 key: None,
284 name: "My test login".to_string(),
285 notes: Some("Test notes".to_string()),
286 identity: None,
287 card: None,
288 secure_note: None,
289 ssh_key: None,
290 bank_account: None,
291 drivers_license: None,
292 passport: None,
293 favorite: false,
294 reprompt: CipherRepromptType::None,
295 organization_use_totp: true,
296 edit: true,
297 permissions: None,
298 view_password: true,
299 local_data: None,
300 attachments: None,
301 attachment_decryption_failures: None,
302 fields: None,
303 password_history: None,
304 creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
305 deleted_date: None,
306 revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
307 archived_date: None,
308 }
309 }
310
311 #[tokio::test]
312 async fn test_move_to_collections_success() {
313 let client = Client::init_test_account(test_bitwarden_com_account()).await;
314
315 let cipher_client = client.vault().ciphers();
316 let cipher_view = test_cipher_view_without_org();
317 let organization_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
318 let collection_ids: Vec<CollectionId> = vec![
319 TEST_COLLECTION_ID_1.parse().unwrap(),
320 TEST_COLLECTION_ID_2.parse().unwrap(),
321 ];
322
323 let result = cipher_client
324 .update_organization_and_collections(
325 cipher_view,
326 organization_id,
327 collection_ids.clone(),
328 )
329 .unwrap();
330
331 assert_eq!(result.organization_id, Some(organization_id));
332 assert_eq!(result.collection_ids, collection_ids);
333 }
334
335 #[tokio::test]
336 async fn test_move_to_collections_already_in_org() {
337 let client = Client::init_test_account(test_bitwarden_com_account()).await;
338
339 let cipher_client = client.vault().ciphers();
340 let mut cipher_view = test_cipher_view_without_org();
341 cipher_view.organization_id = Some(TEST_ORG_ID.parse().unwrap());
342
343 let organization_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
344 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
345
346 let result = cipher_client.update_organization_and_collections(
347 cipher_view,
348 organization_id,
349 collection_ids,
350 );
351
352 assert!(result.is_err());
353 assert!(matches!(
354 result.unwrap_err(),
355 CipherError::OrganizationAlreadySet
356 ));
357 }
358
359 #[tokio::test]
360 async fn test_share_ciphers_bulk_already_in_org() {
361 let client = Client::init_test_account(test_bitwarden_com_account()).await;
362
363 let cipher_client = client.vault().ciphers();
364 let mut cipher_view = test_cipher_view_without_org();
365 cipher_view.organization_id = Some(TEST_ORG_ID.parse().unwrap());
366
367 let organization_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
368 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
369
370 let result = cipher_client
371 .share_ciphers_bulk(vec![cipher_view], organization_id, collection_ids)
372 .await;
373
374 assert!(result.is_err());
375 assert!(matches!(
376 result.unwrap_err(),
377 CipherError::OrganizationAlreadySet
378 ));
379 }
380
381 #[tokio::test]
382 async fn test_move_to_collections_with_attachment_without_key_fails() {
383 let client = Client::init_test_account(test_bitwarden_com_account()).await;
384
385 let cipher_client = client.vault().ciphers();
386 let mut cipher_view = test_cipher_view_without_org();
387
388 cipher_view.attachments = Some(vec![crate::AttachmentView {
390 id: Some("attachment-456".to_string()),
391 url: Some("https://example.com/attachment".to_string()),
392 size: Some("2048".to_string()),
393 size_name: Some("2 KB".to_string()),
394 file_name: Some("test2.txt".to_string()),
395 key: None, }]);
397
398 let organization_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
399 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
400
401 let result = cipher_client.update_organization_and_collections(
402 cipher_view,
403 organization_id,
404 collection_ids,
405 );
406
407 assert!(result.is_err());
409 assert!(matches!(
410 result.unwrap_err(),
411 CipherError::AttachmentsWithoutKeys
412 ));
413 }
414
415 #[tokio::test]
416 async fn test_share_ciphers_bulk_multiple_validation() {
417 let client = Client::init_test_account(test_bitwarden_com_account()).await;
418
419 let repository = MemoryRepository::<Cipher>::default();
421 client
422 .platform()
423 .state()
424 .register_client_managed(std::sync::Arc::new(repository));
425
426 let cipher_client = client.vault().ciphers();
427
428 let cipher_view_1 = test_cipher_view_without_org();
430 let mut cipher_view_2 = test_cipher_view_without_org();
431 cipher_view_2.organization_id = Some(TEST_ORG_ID.parse().unwrap());
432
433 let encrypted_1 = cipher_client.encrypt(cipher_view_1.clone()).await.unwrap();
435 let repository = cipher_client.get_repository().unwrap();
436 repository
437 .set(TEST_CIPHER_ID.parse().unwrap(), encrypted_1.cipher.clone())
438 .await
439 .unwrap();
440
441 let organization_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
442 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
443
444 let result = cipher_client
446 .share_ciphers_bulk(
447 vec![cipher_view_1, cipher_view_2],
448 organization_id,
449 collection_ids,
450 )
451 .await;
452
453 assert!(result.is_err());
454 assert!(matches!(
455 result.unwrap_err(),
456 CipherError::OrganizationAlreadySet
457 ));
458 }
459
460 fn create_encryption_context() -> EncryptionContext {
461 use bitwarden_core::UserId;
462
463 use crate::cipher::Login;
464
465 let cipher = Cipher {
467 partial_data: None,
468 r#type: CipherType::Login,
469 login: Some(Login {
470 username: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
471 password: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
472 password_revision_date: None,
473 uris: None,
474 totp: None,
475 autofill_on_page_load: None,
476 fido2_credentials: None,
477 }),
478 id: Some(TEST_CIPHER_ID.parse().unwrap()),
479 organization_id: Some(TEST_ORG_ID.parse().unwrap()),
480 folder_id: None,
481 collection_ids: vec![TEST_COLLECTION_ID_1.parse().unwrap()],
482 key: None,
483 name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
484 notes: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
485 identity: None,
486 card: None,
487 secure_note: None,
488 ssh_key: None,
489 bank_account: None,
490 drivers_license: None,
491 passport: None,
492 favorite: false,
493 reprompt: CipherRepromptType::None,
494 organization_use_totp: true,
495 edit: true,
496 permissions: None,
497 view_password: true,
498 local_data: None,
499 attachments: None,
500 fields: None,
501 password_history: None,
502 creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
503 deleted_date: None,
504 revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
505 archived_date: None,
506 data: None,
507 };
508
509 let user_id: UserId = "00000000-0000-0000-0000-000000000000".parse().unwrap();
511
512 EncryptionContext {
513 cipher,
514 encrypted_for: user_id,
515 encrypted_by_key_id: None,
516 }
517 }
518
519 #[tokio::test]
520 async fn test_share_cipher_api_success() {
521 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
522 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
523 let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
524
525 let api_client = ApiClient::new_mocked(move |mock| {
526 mock.ciphers_api.expect_put_share().returning(move |_id, _body| {
527 Ok(CipherResponseModel {
528 object: Some("cipher".to_string()),
529 id: Some(cipher_id.into()),
530 organization_id: Some(org_id.into()),
531 r#type: Some(bitwarden_api_api::models::CipherType::Login),
532 name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
533 notes: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
534 login: Some(Box::new(bitwarden_api_api::models::CipherLoginModel {
535 username: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
536 password: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
537 ..Default::default()
538 })),
539 reprompt: Some(bitwarden_api_api::models::CipherRepromptType::None),
540 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
541 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
542 edit: Some(true),
543 view_password: Some(true),
544 organization_use_totp: Some(true),
545 favorite: Some(false),
546 ..Default::default()
547 })
548 });
549 });
550
551 let repository = MemoryRepository::<Cipher>::default();
552 let encryption_context = create_encryption_context();
553 let collection_ids: Vec<CollectionId> = vec![collection_id];
554
555 let result = share_cipher(
556 api_client.ciphers_api(),
557 &repository,
558 encryption_context,
559 collection_ids.clone(),
560 )
561 .await;
562
563 assert!(result.is_ok());
564 let shared_cipher = result.unwrap();
565
566 let stored_cipher = repository
568 .get(TEST_CIPHER_ID.parse().unwrap())
569 .await
570 .unwrap()
571 .expect("Cipher should be stored");
572
573 assert_eq!(stored_cipher.id, shared_cipher.id);
574 assert_eq!(
575 stored_cipher
576 .organization_id
577 .as_ref()
578 .map(ToString::to_string),
579 Some(TEST_ORG_ID.to_string())
580 );
581 assert_eq!(stored_cipher.collection_ids, collection_ids);
582 }
583
584 #[tokio::test]
585 async fn test_share_cipher_api_handles_404() {
586 let api_client = ApiClient::new_mocked(|mock| {
587 mock.ciphers_api
588 .expect_put_share()
589 .returning(|_id, _body| Err(std::io::Error::other("Not found").into()));
590 });
591
592 let repository = MemoryRepository::<Cipher>::default();
593 let encryption_context = create_encryption_context();
594 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
595
596 let result = share_cipher(
597 api_client.ciphers_api(),
598 &repository,
599 encryption_context,
600 collection_ids,
601 )
602 .await;
603
604 assert!(result.is_err());
605 }
606
607 #[tokio::test]
608 async fn test_share_ciphers_bulk_api_success() {
609 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
610 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
611
612 let api_client = ApiClient::new_mocked(move |mock| {
613 mock.ciphers_api.expect_put_share_many().returning(move |_body| {
614 Ok(CipherMiniResponseModelListResponseModel {
615 object: Some("list".to_string()),
616 data: Some(vec![bitwarden_api_api::models::CipherMiniResponseModel {
617 object: Some("cipherMini".to_string()),
618 id: Some(cipher_id.into()),
619 organization_id: Some(org_id.into()),
620 r#type: Some(bitwarden_api_api::models::CipherType::Login),
621 name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
622 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
623 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
624 ..Default::default()
625 }]),
626 continuation_token: None,
627 })
628 });
629 });
630
631 let repository = MemoryRepository::<Cipher>::default();
632
633 let original_cipher = Cipher {
635 partial_data: None,
636 r#type: CipherType::Login,
637 login: Some(crate::cipher::Login {
638 username: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
639 password: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
640 password_revision_date: None,
641 uris: None,
642 totp: None,
643 autofill_on_page_load: None,
644 fido2_credentials: None,
645 }),
646 id: Some(TEST_CIPHER_ID.parse().unwrap()),
647 organization_id: None,
648 folder_id: None,
649 collection_ids: vec![],
650 key: None,
651 name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
652 notes: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
653 identity: None,
654 card: None,
655 secure_note: None,
656 ssh_key: None,
657 bank_account: None,
658 drivers_license: None,
659 passport: None,
660 favorite: true,
661 reprompt: CipherRepromptType::None,
662 organization_use_totp: true,
663 edit: true,
664 permissions: None,
665 view_password: true,
666 local_data: None,
667 attachments: None,
668 fields: None,
669 password_history: None,
670 creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
671 deleted_date: None,
672 revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
673 archived_date: None,
674 data: None,
675 };
676
677 repository
678 .set(TEST_CIPHER_ID.parse().unwrap(), original_cipher)
679 .await
680 .unwrap();
681
682 let encryption_context = create_encryption_context();
683 let collection_ids: Vec<CollectionId> = vec![
684 TEST_COLLECTION_ID_1.parse().unwrap(),
685 TEST_COLLECTION_ID_2.parse().unwrap(),
686 ];
687
688 let result = share_ciphers_bulk(
689 api_client.ciphers_api(),
690 &repository,
691 vec![encryption_context],
692 collection_ids.clone(),
693 )
694 .await;
695
696 assert!(result.is_ok());
697 let shared_ciphers = result.unwrap();
698 assert_eq!(shared_ciphers.len(), 1);
699
700 let shared_cipher = &shared_ciphers[0];
701 assert_eq!(
702 shared_cipher
703 .organization_id
704 .as_ref()
705 .map(ToString::to_string),
706 Some(TEST_ORG_ID.to_string())
707 );
708 assert_eq!(shared_cipher.collection_ids, collection_ids);
709
710 let stored_cipher = repository
712 .get(TEST_CIPHER_ID.parse().unwrap())
713 .await
714 .unwrap()
715 .expect("Cipher should be stored");
716
717 assert_eq!(stored_cipher.id, shared_cipher.id);
718 assert!(stored_cipher.favorite); }
720
721 #[tokio::test]
726 async fn test_share_ciphers_bulk_persists_a_gated_write_return() {
727 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
728 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
729
730 let api_client = ApiClient::new_mocked(move |mock| {
731 mock.ciphers_api
732 .expect_put_share_many()
733 .returning(move |_body| {
734 Ok(CipherMiniResponseModelListResponseModel {
735 object: Some("list".to_string()),
736 data: Some(vec![bitwarden_api_api::models::CipherMiniResponseModel {
737 object: Some("cipherMini".to_string()),
738 id: Some(cipher_id.into()),
739 organization_id: Some(org_id.into()),
740 r#type: Some(bitwarden_api_api::models::CipherType::Login),
741 partial_data: Some(
742 r#"{"strippedFields":["login","name","notes"]}"#.to_string(),
743 ),
744 name: None,
745 notes: None,
746 login: None,
747 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
748 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
749 ..Default::default()
750 }]),
751 continuation_token: None,
752 })
753 });
754 });
755
756 let repository = MemoryRepository::<Cipher>::default();
757
758 let original_cipher = Cipher {
761 partial_data: None,
762 r#type: CipherType::Login,
763 login: Some(crate::cipher::Login {
764 username: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
765 password: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
766 password_revision_date: None,
767 uris: None,
768 totp: None,
769 autofill_on_page_load: None,
770 fido2_credentials: None,
771 }),
772 id: Some(TEST_CIPHER_ID.parse().unwrap()),
773 organization_id: None,
774 folder_id: Some(crate::FolderId::new(uuid::uuid!(
775 "b1111111-1111-1111-1111-111111111111"
776 ))),
777 collection_ids: vec![],
778 key: None,
779 name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
780 notes: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
781 identity: None,
782 card: None,
783 secure_note: None,
784 ssh_key: None,
785 bank_account: None,
786 drivers_license: None,
787 passport: None,
788 favorite: true,
789 reprompt: CipherRepromptType::None,
790 organization_use_totp: true,
791 edit: true,
792 permissions: None,
793 view_password: true,
794 local_data: None,
795 attachments: None,
796 fields: None,
797 password_history: None,
798 creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
799 deleted_date: None,
800 revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
801 archived_date: None,
802 data: None,
803 };
804 let original_folder_id = original_cipher.folder_id;
805
806 repository
807 .set(TEST_CIPHER_ID.parse().unwrap(), original_cipher)
808 .await
809 .unwrap();
810
811 let encryption_context = create_encryption_context();
812 let collection_ids: Vec<CollectionId> = vec![
813 TEST_COLLECTION_ID_1.parse().unwrap(),
814 TEST_COLLECTION_ID_2.parse().unwrap(),
815 ];
816
817 let result = share_ciphers_bulk(
818 api_client.ciphers_api(),
819 &repository,
820 vec![encryption_context],
821 collection_ids.clone(),
822 )
823 .await;
824
825 assert!(result.is_ok());
826 let shared_ciphers = result.unwrap();
827 assert_eq!(shared_ciphers.len(), 1);
828
829 let stored_cipher = repository
830 .get(TEST_CIPHER_ID.parse().unwrap())
831 .await
832 .unwrap()
833 .expect("Cipher should be stored");
834
835 assert!(
836 stored_cipher.partial_data.is_some(),
837 "gated write-return must stay gated in the repository"
838 );
839 assert!(
840 stored_cipher.login.is_none(),
841 "no secret field may reach local state"
842 );
843 assert!(stored_cipher.name.is_none());
844 assert_eq!(stored_cipher.collection_ids, collection_ids);
845 assert!(
846 stored_cipher.favorite,
847 "local-only fields must survive the merge"
848 );
849 assert_eq!(stored_cipher.folder_id, original_folder_id);
850 }
851
852 #[tokio::test]
857 async fn test_share_ciphers_bulk_evicts_a_stripped_write_return() {
858 let returned_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
859 let stripped_id: CipherId = "11111111-2222-3333-4444-555555555555".parse().unwrap();
860 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
861
862 let api_client = ApiClient::new_mocked(move |mock| {
863 mock.ciphers_api
864 .expect_put_share_many()
865 .returning(move |_body| {
866 Ok(CipherMiniResponseModelListResponseModel {
869 object: Some("list".to_string()),
870 data: Some(vec![bitwarden_api_api::models::CipherMiniResponseModel {
871 object: Some("cipherMini".to_string()),
872 id: Some(returned_id.into()),
873 organization_id: Some(org_id.into()),
874 r#type: Some(bitwarden_api_api::models::CipherType::Login),
875 name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
876 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
877 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
878 ..Default::default()
879 }]),
880 continuation_token: None,
881 })
882 });
883 });
884
885 let repository = MemoryRepository::<Cipher>::default();
886
887 let mut original_returned = create_encryption_context().cipher;
890 original_returned.organization_id = None;
891 original_returned.collection_ids = vec![];
892 repository
893 .set(returned_id, original_returned)
894 .await
895 .unwrap();
896
897 let mut original_stripped = create_encryption_context().cipher;
898 original_stripped.id = Some(stripped_id);
899 original_stripped.organization_id = None;
900 original_stripped.collection_ids = vec![];
901 repository
902 .set(stripped_id, original_stripped)
903 .await
904 .unwrap();
905
906 let mut encryption_context_returned = create_encryption_context();
907 encryption_context_returned.cipher.id = Some(returned_id);
908 let mut encryption_context_stripped = create_encryption_context();
909 encryption_context_stripped.cipher.id = Some(stripped_id);
910
911 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
912
913 let result = share_ciphers_bulk(
914 api_client.ciphers_api(),
915 &repository,
916 vec![encryption_context_returned, encryption_context_stripped],
917 collection_ids,
918 )
919 .await;
920
921 assert!(result.is_ok());
922 let shared_ciphers = result.unwrap();
923 assert_eq!(
924 shared_ciphers.len(),
925 1,
926 "only the ciphers the server returned are reported back to the caller"
927 );
928
929 let stored_returned = repository.get(returned_id).await.unwrap();
930 assert!(
931 stored_returned
932 .and_then(|c| c.organization_id)
933 .is_some_and(|id| id == org_id),
934 "the cipher the server returned must be merged and persisted with its new org id"
935 );
936
937 let stored_stripped = repository.get(stripped_id).await.unwrap();
938 assert!(
939 stored_stripped.is_none(),
940 "a cipher omitted from the write-return must be evicted, not left as a stale \
941 pre-share copy"
942 );
943 }
944
945 #[tokio::test]
946 async fn test_share_ciphers_bulk_api_handles_error() {
947 let api_client = ApiClient::new_mocked(|mock| {
948 mock.ciphers_api
949 .expect_put_share_many()
950 .returning(|_body| Err(std::io::Error::other("Server error").into()));
951 });
952
953 let repository = MemoryRepository::<Cipher>::default();
954 let encryption_context = create_encryption_context();
955 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
956
957 let result = share_ciphers_bulk(
958 api_client.ciphers_api(),
959 &repository,
960 vec![encryption_context],
961 collection_ids,
962 )
963 .await;
964
965 assert!(result.is_err());
966 }
967
968 async fn make_test_client_with_wiremock(mock_server: &wiremock::MockServer) -> Client {
969 use bitwarden_core::{
970 ClientSettings, DeviceType, UserId,
971 key_management::crypto::{
972 InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest,
973 },
974 };
975 use bitwarden_crypto::{EncString, Kdf};
976
977 let settings = ClientSettings {
978 identity_url: format!("http://{}", mock_server.address()),
979 api_url: format!("http://{}", mock_server.address()),
980 user_agent: "Bitwarden Test".into(),
981 device_type: DeviceType::SDK,
982 device_identifier: None,
983 bitwarden_client_version: None,
984 bitwarden_package_type: None,
985 };
986
987 let client = Client::new_test(Some(settings));
988
989 let user_request = InitUserCryptoRequest {
990 user_id: Some(UserId::new(uuid::uuid!("060000fb-0922-4dd3-b170-6e15cb5df8c8"))),
991 kdf_params: Kdf::PBKDF2 {
992 iterations: 600_000.try_into().unwrap(),
993 },
994 email: "[email protected]".to_owned(),
995 account_cryptographic_state: WrappedAccountCryptographicState::V1 {
996 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(),
997 },
998 method: InitUserCryptoMethod::MasterPasswordUnlock {
999 password: "asdfasdfasdf".to_owned(),
1000 master_password_unlock: MasterPasswordUnlockData {
1001 kdf: Kdf::PBKDF2 {
1002 iterations: 600_000.try_into().unwrap(),
1003 },
1004 master_key_wrapped_user_key: "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=".parse().unwrap(),
1005 salt: "[email protected]".to_owned(),
1006 contained_key_id: None,
1007 },
1008 },
1009 upgrade_token: None,
1010 };
1011
1012 let org_request = InitOrgCryptoRequest {
1013 organization_keys: std::collections::HashMap::from([(
1014 TEST_ORG_ID.parse().unwrap(),
1015 "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==".parse().unwrap()
1016 )])
1017 };
1018
1019 client
1020 .crypto()
1021 .initialize_user_crypto(user_request)
1022 .await
1023 .unwrap();
1024 client
1025 .crypto()
1026 .initialize_org_crypto(org_request)
1027 .await
1028 .unwrap();
1029
1030 client
1031 }
1032
1033 #[tokio::test]
1034 async fn test_share_cipher_with_password_history() {
1035 use bitwarden_test::start_api_mock;
1036 use wiremock::{
1037 Mock, ResponseTemplate,
1038 matchers::{method, path_regex},
1039 };
1040 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
1041 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
1042 let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
1043
1044 let mut cipher_view = test_cipher_view_without_org();
1045 if let Some(ref mut login) = cipher_view.login {
1046 login.password = Some("original_password_123".to_string());
1047 }
1048
1049 let mock = Mock::given(method("PUT"))
1051 .and(path_regex(r"/ciphers/[a-f0-9-]+/share"))
1052 .and(wiremock::matchers::body_string_contains("passwordHistory"))
1053 .respond_with(move |req: &wiremock::Request| {
1054 let body_bytes = req.body.as_slice();
1055 let request_body: bitwarden_api_api::models::CipherShareRequestModel =
1056 serde_json::from_slice(body_bytes).expect("Failed to parse request body");
1057
1058 let response = CipherResponseModel {
1060 object: Some("cipher".to_string()),
1061 id: Some(cipher_id.into()),
1062 organization_id: Some(
1063 request_body
1064 .cipher
1065 .organization_id
1066 .unwrap()
1067 .parse()
1068 .unwrap(),
1069 ),
1070 r#type: request_body.cipher.r#type,
1071 name: request_body.cipher.name,
1072 notes: request_body.cipher.notes,
1073 login: request_body.cipher.login,
1074 reprompt: request_body.cipher.reprompt,
1075 password_history: request_body.cipher.password_history,
1076 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
1077 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
1078 edit: Some(true),
1079 view_password: Some(true),
1080 organization_use_totp: Some(true),
1081 favorite: request_body.cipher.favorite,
1082 fields: request_body.cipher.fields,
1083 key: request_body.cipher.key,
1084 ..Default::default()
1085 };
1086
1087 ResponseTemplate::new(200).set_body_json(&response)
1088 });
1089
1090 let (mock_server, _config) = start_api_mock(vec![mock]).await;
1092 let client = make_test_client_with_wiremock(&mock_server).await;
1093 let repository = std::sync::Arc::new(MemoryRepository::<Cipher>::default());
1094 let cipher_client = client.vault().ciphers();
1095 let original = cipher_view.clone();
1096 repository
1097 .set(
1098 TEST_CIPHER_ID.parse().unwrap(),
1099 cipher_client
1100 .encrypt(original.clone())
1101 .await
1102 .unwrap()
1103 .cipher,
1104 )
1105 .await
1106 .unwrap();
1107
1108 client
1109 .platform()
1110 .state()
1111 .register_client_managed(repository.clone());
1112
1113 if let Some(ref mut login) = cipher_view.login {
1115 login.password = Some("new_password_456".to_string());
1116 }
1117
1118 let result = cipher_client
1119 .share_cipher(
1120 cipher_view.clone(),
1121 org_id,
1122 vec![collection_id],
1123 Some(original),
1124 )
1125 .await;
1126
1127 let shared_cipher = result.unwrap();
1128 assert_eq!(shared_cipher.organization_id, Some(org_id));
1129 let history = shared_cipher.password_history.unwrap();
1130 assert_eq!(
1131 history.len(),
1132 1,
1133 "Password history should have 1 entry for the changed password"
1134 );
1135 assert_eq!(
1136 history[0].password, "original_password_123",
1137 "Password history should contain the original password"
1138 );
1139 assert_eq!(
1140 shared_cipher.login.as_ref().unwrap().password,
1141 Some("new_password_456".to_string()),
1142 "New password should be set"
1143 );
1144 }
1145
1146 #[tokio::test]
1147 async fn test_share_ciphers_bulk_with_password_history() {
1148 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
1149 let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
1150
1151 let mut cipher_view1 = test_cipher_view_without_org();
1152 cipher_view1.id = Some(TEST_CIPHER_ID.parse().unwrap());
1153 if let Some(ref mut login) = cipher_view1.login {
1154 login.password = Some("original_password_1".to_string());
1155 }
1156
1157 let mut cipher_view2 = test_cipher_view_without_org();
1158 cipher_view2.id = Some("11111111-2222-3333-4444-555555555555".parse().unwrap());
1159 if let Some(ref mut login) = cipher_view2.login {
1160 login.password = Some("original_password_2".to_string());
1161 }
1162
1163 let mock = Mock::given(method("PUT"))
1165 .and(path("/ciphers/share"))
1166 .and(wiremock::matchers::body_string_contains("passwordHistory"))
1167 .respond_with(move |req: &wiremock::Request| {
1168 let body_bytes = req.body.as_slice();
1169 let request_body: bitwarden_api_api::models::CipherBulkShareRequestModel =
1170 serde_json::from_slice(body_bytes).expect("Failed to parse request body");
1171
1172 let ciphers: Vec<_> = request_body
1174 .ciphers
1175 .into_iter()
1176 .map(
1177 |cipher| bitwarden_api_api::models::CipherMiniResponseModel {
1178 object: Some("cipherMini".to_string()),
1179 id: Some(cipher.id),
1180 organization_id: cipher.organization_id.and_then(|id| id.parse().ok()),
1181 r#type: cipher.r#type,
1182 name: cipher.name,
1183 notes: cipher.notes,
1184 login: cipher.login,
1185 reprompt: cipher.reprompt,
1186 password_history: cipher.password_history,
1187 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
1188 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
1189 organization_use_totp: Some(true),
1190 fields: cipher.fields,
1191 key: cipher.key,
1192 ..Default::default()
1193 },
1194 )
1195 .collect();
1196
1197 let response =
1198 bitwarden_api_api::models::CipherMiniResponseModelListResponseModel {
1199 object: Some("list".to_string()),
1200 data: Some(ciphers),
1201 continuation_token: None,
1202 };
1203
1204 ResponseTemplate::new(200).set_body_json(&response)
1205 });
1206
1207 let (mock_server, _config) = start_api_mock(vec![mock]).await;
1209 let client = make_test_client_with_wiremock(&mock_server).await;
1210 let repository = std::sync::Arc::new(MemoryRepository::<Cipher>::default());
1211 let cipher_client = client.vault().ciphers();
1212
1213 let encrypted_original1 = cipher_client.encrypt(cipher_view1.clone()).await.unwrap();
1214 repository
1215 .set(
1216 encrypted_original1.cipher.id.unwrap(),
1217 encrypted_original1.cipher.clone(),
1218 )
1219 .await
1220 .unwrap();
1221
1222 let encrypted_original2 = cipher_client.encrypt(cipher_view2.clone()).await.unwrap();
1223 repository
1224 .set(
1225 encrypted_original2.cipher.id.unwrap(),
1226 encrypted_original2.cipher.clone(),
1227 )
1228 .await
1229 .unwrap();
1230
1231 client
1232 .platform()
1233 .state()
1234 .register_client_managed(repository.clone());
1235
1236 if let Some(ref mut login) = cipher_view1.login {
1238 login.password = Some("new_password_1".to_string());
1239 }
1240 if let Some(ref mut login) = cipher_view2.login {
1241 login.password = Some("new_password_2".to_string());
1242 }
1243
1244 let result = cipher_client
1245 .share_ciphers_bulk(
1246 vec![cipher_view1, cipher_view2],
1247 org_id,
1248 vec![collection_id],
1249 )
1250 .await;
1251
1252 let shared_ciphers = result.unwrap();
1253 assert_eq!(shared_ciphers.len(), 2);
1254
1255 assert_eq!(
1256 shared_ciphers[0].password_history.clone().unwrap()[0].password,
1257 "original_password_1"
1258 );
1259 assert_eq!(
1260 shared_ciphers[0].login.clone().unwrap().password,
1261 Some("new_password_1".to_string())
1262 );
1263
1264 assert_eq!(
1265 shared_ciphers[1].password_history.clone().unwrap()[0].password,
1266 "original_password_2"
1267 );
1268 assert_eq!(
1269 shared_ciphers[1].login.clone().unwrap().password,
1270 Some("new_password_2".to_string())
1271 );
1272 }
1273}