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