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, cipher::cipher::PartialCipher,
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 = require!(encrypted_cipher.cipher.id);
26 let cipher_uuid: uuid::Uuid = cipher_id.into();
27
28 let req = CipherShareRequestModel::new(
29 collection_ids
30 .iter()
31 .map(<CollectionId as ToString>::to_string)
32 .collect(),
33 encrypted_cipher.into(),
34 );
35
36 let response = api_client.put_share(cipher_uuid, Some(req)).await?;
37
38 let mut new_cipher: Cipher = response.merge_with_cipher(None)?;
39 new_cipher.collection_ids = collection_ids;
40
41 repository.set(cipher_id, new_cipher.clone()).await?;
42
43 Ok(new_cipher)
44}
45
46async fn share_ciphers_bulk(
49 api_client: &dyn CiphersApi,
50 repository: &dyn Repository<Cipher>,
51 encrypted_ciphers: Vec<EncryptionContext>,
52 collection_ids: Vec<CollectionId>,
53) -> Result<Vec<Cipher>, CipherError> {
54 let request = CipherBulkShareRequestModel::new(
55 collection_ids
56 .iter()
57 .map(<CollectionId as ToString>::to_string)
58 .collect(),
59 encrypted_ciphers
60 .into_iter()
61 .map(|ec| ec.try_into())
62 .collect::<Result<Vec<_>, _>>()?,
63 );
64
65 let response = api_client.put_share_many(Some(request)).await?;
66
67 let cipher_minis = response.data.unwrap_or_default();
68 let mut results = Vec::new();
69
70 for cipher_mini in cipher_minis {
71 let orig_cipher = repository
74 .get(CipherId::new(
75 cipher_mini.id.ok_or(MissingFieldError("id"))?,
76 ))
77 .await?;
78
79 let cipher: Cipher = Cipher {
80 id: cipher_mini.id.map(CipherId::new),
81 organization_id: cipher_mini.organization_id.map(OrganizationId::new),
82 key: EncString::try_from_optional(cipher_mini.key)?,
83 name: require!(EncString::try_from_optional(cipher_mini.name)?),
84 notes: EncString::try_from_optional(cipher_mini.notes)?,
85 r#type: require!(cipher_mini.r#type).try_into()?,
86 login: cipher_mini.login.map(|l| (*l).try_into()).transpose()?,
87 identity: cipher_mini.identity.map(|i| (*i).try_into()).transpose()?,
88 card: cipher_mini.card.map(|c| (*c).try_into()).transpose()?,
89 secure_note: cipher_mini
90 .secure_note
91 .map(|s| (*s).try_into())
92 .transpose()?,
93 ssh_key: cipher_mini.ssh_key.map(|s| (*s).try_into()).transpose()?,
94 bank_account: cipher_mini
95 .bank_account
96 .map(|b| (*b).try_into())
97 .transpose()?,
98 reprompt: cipher_mini
99 .reprompt
100 .map(|r| r.try_into())
101 .transpose()?
102 .unwrap_or(CipherRepromptType::None),
103 organization_use_totp: cipher_mini.organization_use_totp.unwrap_or(true),
104 attachments: cipher_mini
105 .attachments
106 .map(|a| a.into_iter().map(|a| a.try_into()).collect())
107 .transpose()?,
108 fields: cipher_mini
109 .fields
110 .map(|f| f.into_iter().map(|f| f.try_into()).collect())
111 .transpose()?,
112 password_history: cipher_mini
113 .password_history
114 .map(|p| p.into_iter().map(|p| p.try_into()).collect())
115 .transpose()?,
116 creation_date: require!(cipher_mini.creation_date)
117 .parse()
118 .map_err(Into::<VaultParseError>::into)?,
119 deleted_date: cipher_mini
120 .deleted_date
121 .map(|d| d.parse())
122 .transpose()
123 .map_err(Into::<VaultParseError>::into)?,
124 revision_date: require!(cipher_mini.revision_date)
125 .parse()
126 .map_err(Into::<VaultParseError>::into)?,
127 archived_date: orig_cipher
128 .as_ref()
129 .map(|c| c.archived_date)
130 .unwrap_or_default(),
131 edit: orig_cipher.as_ref().map(|c| c.edit).unwrap_or_default(),
132 favorite: orig_cipher.as_ref().map(|c| c.favorite).unwrap_or_default(),
133 folder_id: orig_cipher
134 .as_ref()
135 .map(|c| c.folder_id)
136 .unwrap_or_default(),
137 permissions: orig_cipher
138 .as_ref()
139 .map(|c| c.permissions)
140 .unwrap_or_default(),
141 view_password: orig_cipher
142 .as_ref()
143 .map(|c| c.view_password)
144 .unwrap_or_default(),
145 local_data: orig_cipher.map(|c| c.local_data).unwrap_or_default(),
146 collection_ids: collection_ids.clone(),
147 data: None,
148 };
149
150 repository.set(require!(cipher.id), cipher.clone()).await?;
151 results.push(cipher)
152 }
153
154 Ok(results)
155}
156
157#[cfg_attr(feature = "wasm", wasm_bindgen)]
158impl CiphersClient {
159 fn update_organization_and_collections(
160 &self,
161 mut cipher_view: CipherView,
162 organization_id: OrganizationId,
163 collection_ids: Vec<CollectionId>,
164 ) -> Result<CipherView, CipherError> {
165 let organization_id = &organization_id;
166 if cipher_view.organization_id.is_some() {
167 return Err(CipherError::OrganizationAlreadySet);
168 }
169
170 cipher_view = self.move_to_organization(cipher_view, *organization_id)?;
171 cipher_view.collection_ids = collection_ids;
172 Ok(cipher_view)
173 }
174
175 pub async fn share_cipher(
177 &self,
178 mut cipher_view: CipherView,
179 organization_id: OrganizationId,
180 collection_ids: Vec<CollectionId>,
181 original_cipher_view: Option<CipherView>,
182 ) -> Result<CipherView, CipherError> {
183 cipher_view = self.update_organization_and_collections(
184 cipher_view,
185 organization_id,
186 collection_ids.clone(),
187 )?;
188
189 self.update_password_history(&mut cipher_view, original_cipher_view)
190 .await?;
191
192 let encrypted_cipher = self.encrypt(cipher_view).await?;
193
194 let api_client = &self.client.internal.get_api_configurations().api_client;
195
196 let result_cipher = share_cipher(
197 api_client.ciphers_api(),
198 &*self.get_repository()?,
199 encrypted_cipher,
200 collection_ids,
201 )
202 .await?;
203 Ok(self.decrypt(result_cipher).await?)
204 }
205
206 async fn update_password_history(
207 &self,
208 cipher_view: &mut CipherView,
209 mut original_cipher_view: Option<CipherView>,
210 ) -> Result<(), CipherError> {
211 if let Some(cipher_id) = cipher_view.id
212 && original_cipher_view.is_none()
213 && let Some(cipher) = self.get_repository()?.get(cipher_id).await?
214 {
215 original_cipher_view = Some(self.decrypt(cipher).await?);
216 }
217 if let Some(original_cipher_view) = original_cipher_view {
218 cipher_view.update_password_history(&original_cipher_view);
219 }
220 Ok(())
221 }
222
223 async fn prepare_encrypted_ciphers_for_bulk_share(
224 &self,
225 cipher_views: Vec<CipherView>,
226 organization_id: OrganizationId,
227 collection_ids: Vec<CollectionId>,
228 ) -> Result<Vec<EncryptionContext>, CipherError> {
229 let mut encrypted_ciphers: Vec<EncryptionContext> = Vec::new();
230 for mut cv in cipher_views {
231 cv = self.update_organization_and_collections(
232 cv,
233 organization_id,
234 collection_ids.clone(),
235 )?;
236 self.update_password_history(&mut cv, None).await?;
237 encrypted_ciphers.push(self.encrypt(cv).await?);
238 }
239 Ok(encrypted_ciphers)
240 }
241
242 #[cfg(feature = "uniffi")]
243 pub async fn prepare_ciphers_for_bulk_share(
247 &self,
248 cipher_views: Vec<CipherView>,
249 organization_id: OrganizationId,
250 collection_ids: Vec<CollectionId>,
251 ) -> Result<Vec<EncryptionContext>, CipherError> {
252 self.prepare_encrypted_ciphers_for_bulk_share(cipher_views, organization_id, collection_ids)
253 .await
254 }
255
256 pub async fn share_ciphers_bulk(
259 &self,
260 cipher_views: Vec<CipherView>,
261 organization_id: OrganizationId,
262 collection_ids: Vec<CollectionId>,
263 ) -> Result<Vec<CipherView>, CipherError> {
264 let encrypted_ciphers = self
265 .prepare_encrypted_ciphers_for_bulk_share(
266 cipher_views,
267 organization_id,
268 collection_ids.clone(),
269 )
270 .await?;
271
272 let api_client = &self.client.internal.get_api_configurations().api_client;
273
274 let result_ciphers = share_ciphers_bulk(
275 api_client.ciphers_api(),
276 &*self.get_repository()?,
277 encrypted_ciphers,
278 collection_ids,
279 )
280 .await?;
281
282 Ok(
283 futures::future::try_join_all(result_ciphers.into_iter().map(|c| self.decrypt(c)))
284 .await?,
285 )
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use bitwarden_api_api::{
292 apis::ApiClient,
293 models::{CipherMiniResponseModelListResponseModel, CipherResponseModel},
294 };
295 use bitwarden_core::{
296 Client,
297 client::test_accounts::test_bitwarden_com_account,
298 key_management::{
299 MasterPasswordUnlockData, account_cryptographic_state::WrappedAccountCryptographicState,
300 },
301 };
302 use bitwarden_test::{MemoryRepository, start_api_mock};
303 use wiremock::{
304 Mock, ResponseTemplate,
305 matchers::{method, path},
306 };
307
308 use super::*;
309 use crate::{CipherRepromptType, CipherType, LoginView, VaultClientExt};
310
311 const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
312 const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8";
313 const TEST_COLLECTION_ID_1: &str = "c1111111-1111-1111-1111-111111111111";
314 const TEST_COLLECTION_ID_2: &str = "c2222222-2222-2222-2222-222222222222";
315
316 fn test_cipher_view_without_org() -> CipherView {
317 CipherView {
318 r#type: CipherType::Login,
319 login: Some(LoginView {
320 username: Some("[email protected]".to_string()),
321 password: Some("password123".to_string()),
322 password_revision_date: None,
323 uris: None,
324 totp: None,
325 autofill_on_page_load: None,
326 fido2_credentials: None,
327 }),
328 id: Some(TEST_CIPHER_ID.parse().unwrap()),
329 organization_id: None,
330 folder_id: None,
331 collection_ids: vec![],
332 key: None,
333 name: "My test login".to_string(),
334 notes: Some("Test notes".to_string()),
335 identity: None,
336 card: None,
337 secure_note: None,
338 ssh_key: None,
339 bank_account: 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()).await.unwrap();
484 let repository = cipher_client.get_repository().unwrap();
485 repository
486 .set(TEST_CIPHER_ID.parse().unwrap(), 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 bank_account: None,
538 favorite: false,
539 reprompt: CipherRepromptType::None,
540 organization_use_totp: true,
541 edit: true,
542 permissions: None,
543 view_password: true,
544 local_data: None,
545 attachments: None,
546 fields: None,
547 password_history: None,
548 creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
549 deleted_date: None,
550 revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
551 archived_date: None,
552 data: None,
553 };
554
555 let user_id: UserId = "00000000-0000-0000-0000-000000000000".parse().unwrap();
557
558 EncryptionContext {
559 cipher,
560 encrypted_for: user_id,
561 }
562 }
563
564 #[tokio::test]
565 async fn test_share_cipher_api_success() {
566 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
567 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
568 let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
569
570 let api_client = ApiClient::new_mocked(move |mock| {
571 mock.ciphers_api.expect_put_share().returning(move |_id, _body| {
572 Ok(CipherResponseModel {
573 object: Some("cipher".to_string()),
574 id: Some(cipher_id.into()),
575 organization_id: Some(org_id.into()),
576 r#type: Some(bitwarden_api_api::models::CipherType::Login),
577 name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
578 notes: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
579 login: Some(Box::new(bitwarden_api_api::models::CipherLoginModel {
580 username: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
581 password: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
582 ..Default::default()
583 })),
584 reprompt: Some(bitwarden_api_api::models::CipherRepromptType::None),
585 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
586 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
587 edit: Some(true),
588 view_password: Some(true),
589 organization_use_totp: Some(true),
590 favorite: Some(false),
591 ..Default::default()
592 })
593 });
594 });
595
596 let repository = MemoryRepository::<Cipher>::default();
597 let encryption_context = create_encryption_context();
598 let collection_ids: Vec<CollectionId> = vec![collection_id];
599
600 let result = share_cipher(
601 api_client.ciphers_api(),
602 &repository,
603 encryption_context,
604 collection_ids.clone(),
605 )
606 .await;
607
608 assert!(result.is_ok());
609 let shared_cipher = result.unwrap();
610
611 let stored_cipher = repository
613 .get(TEST_CIPHER_ID.parse().unwrap())
614 .await
615 .unwrap()
616 .expect("Cipher should be stored");
617
618 assert_eq!(stored_cipher.id, shared_cipher.id);
619 assert_eq!(
620 stored_cipher
621 .organization_id
622 .as_ref()
623 .map(ToString::to_string),
624 Some(TEST_ORG_ID.to_string())
625 );
626 assert_eq!(stored_cipher.collection_ids, collection_ids);
627 }
628
629 #[tokio::test]
630 async fn test_share_cipher_api_handles_404() {
631 let api_client = ApiClient::new_mocked(|mock| {
632 mock.ciphers_api.expect_put_share().returning(|_id, _body| {
633 Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
634 "Not found",
635 )))
636 });
637 });
638
639 let repository = MemoryRepository::<Cipher>::default();
640 let encryption_context = create_encryption_context();
641 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
642
643 let result = share_cipher(
644 api_client.ciphers_api(),
645 &repository,
646 encryption_context,
647 collection_ids,
648 )
649 .await;
650
651 assert!(result.is_err());
652 }
653
654 #[tokio::test]
655 async fn test_share_ciphers_bulk_api_success() {
656 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
657 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
658
659 let api_client = ApiClient::new_mocked(move |mock| {
660 mock.ciphers_api.expect_put_share_many().returning(move |_body| {
661 Ok(CipherMiniResponseModelListResponseModel {
662 object: Some("list".to_string()),
663 data: Some(vec![bitwarden_api_api::models::CipherMiniResponseModel {
664 object: Some("cipherMini".to_string()),
665 id: Some(cipher_id.into()),
666 organization_id: Some(org_id.into()),
667 r#type: Some(bitwarden_api_api::models::CipherType::Login),
668 name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
669 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
670 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
671 ..Default::default()
672 }]),
673 continuation_token: None,
674 })
675 });
676 });
677
678 let repository = MemoryRepository::<Cipher>::default();
679
680 let original_cipher = Cipher {
682 r#type: CipherType::Login,
683 login: Some(crate::cipher::Login {
684 username: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
685 password: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
686 password_revision_date: None,
687 uris: None,
688 totp: None,
689 autofill_on_page_load: None,
690 fido2_credentials: None,
691 }),
692 id: Some(TEST_CIPHER_ID.parse().unwrap()),
693 organization_id: None,
694 folder_id: None,
695 collection_ids: vec![],
696 key: None,
697 name: "2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap(),
698 notes: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
699 identity: None,
700 card: None,
701 secure_note: None,
702 ssh_key: None,
703 bank_account: None,
704 favorite: true,
705 reprompt: CipherRepromptType::None,
706 organization_use_totp: true,
707 edit: true,
708 permissions: None,
709 view_password: true,
710 local_data: None,
711 attachments: None,
712 fields: None,
713 password_history: None,
714 creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
715 deleted_date: None,
716 revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
717 archived_date: None,
718 data: None,
719 };
720
721 repository
722 .set(TEST_CIPHER_ID.parse().unwrap(), original_cipher)
723 .await
724 .unwrap();
725
726 let encryption_context = create_encryption_context();
727 let collection_ids: Vec<CollectionId> = vec![
728 TEST_COLLECTION_ID_1.parse().unwrap(),
729 TEST_COLLECTION_ID_2.parse().unwrap(),
730 ];
731
732 let result = share_ciphers_bulk(
733 api_client.ciphers_api(),
734 &repository,
735 vec![encryption_context],
736 collection_ids.clone(),
737 )
738 .await;
739
740 assert!(result.is_ok());
741 let shared_ciphers = result.unwrap();
742 assert_eq!(shared_ciphers.len(), 1);
743
744 let shared_cipher = &shared_ciphers[0];
745 assert_eq!(
746 shared_cipher
747 .organization_id
748 .as_ref()
749 .map(ToString::to_string),
750 Some(TEST_ORG_ID.to_string())
751 );
752 assert_eq!(shared_cipher.collection_ids, collection_ids);
753
754 let stored_cipher = repository
756 .get(TEST_CIPHER_ID.parse().unwrap())
757 .await
758 .unwrap()
759 .expect("Cipher should be stored");
760
761 assert_eq!(stored_cipher.id, shared_cipher.id);
762 assert!(stored_cipher.favorite); }
764
765 #[tokio::test]
766 async fn test_share_ciphers_bulk_api_handles_error() {
767 let api_client = ApiClient::new_mocked(|mock| {
768 mock.ciphers_api.expect_put_share_many().returning(|_body| {
769 Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
770 "Server error",
771 )))
772 });
773 });
774
775 let repository = MemoryRepository::<Cipher>::default();
776 let encryption_context = create_encryption_context();
777 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
778
779 let result = share_ciphers_bulk(
780 api_client.ciphers_api(),
781 &repository,
782 vec![encryption_context],
783 collection_ids,
784 )
785 .await;
786
787 assert!(result.is_err());
788 }
789
790 async fn make_test_client_with_wiremock(mock_server: &wiremock::MockServer) -> Client {
791 use bitwarden_core::{
792 ClientSettings, DeviceType, UserId,
793 key_management::crypto::{
794 InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest,
795 },
796 };
797 use bitwarden_crypto::{EncString, Kdf};
798
799 let settings = ClientSettings {
800 identity_url: format!("http://{}", mock_server.address()),
801 api_url: format!("http://{}", mock_server.address()),
802 user_agent: "Bitwarden Test".into(),
803 device_type: DeviceType::SDK,
804 device_identifier: None,
805 bitwarden_client_version: None,
806 bitwarden_package_type: None,
807 };
808
809 let client = Client::new_test(Some(settings));
810
811 client
812 .internal
813 .load_flags(std::collections::HashMap::from([(
814 "enableCipherKeyEncryption".to_owned(),
815 true,
816 )]))
817 .await;
818
819 let user_request = InitUserCryptoRequest {
820 user_id: Some(UserId::new(uuid::uuid!("060000fb-0922-4dd3-b170-6e15cb5df8c8"))),
821 kdf_params: Kdf::PBKDF2 {
822 iterations: 600_000.try_into().unwrap(),
823 },
824 email: "[email protected]".to_owned(),
825 account_cryptographic_state: WrappedAccountCryptographicState::V1 {
826 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(),
827 },
828 method: InitUserCryptoMethod::MasterPasswordUnlock {
829 password: "asdfasdfasdf".to_owned(),
830 master_password_unlock: MasterPasswordUnlockData {
831 kdf: Kdf::PBKDF2 {
832 iterations: 600_000.try_into().unwrap(),
833 },
834 master_key_wrapped_user_key: "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=".parse().unwrap(),
835 salt: "[email protected]".to_owned(),
836 },
837 },
838 upgrade_token: None,
839 };
840
841 let org_request = InitOrgCryptoRequest {
842 organization_keys: std::collections::HashMap::from([(
843 TEST_ORG_ID.parse().unwrap(),
844 "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==".parse().unwrap()
845 )])
846 };
847
848 client
849 .crypto()
850 .initialize_user_crypto(user_request)
851 .await
852 .unwrap();
853 client
854 .crypto()
855 .initialize_org_crypto(org_request)
856 .await
857 .unwrap();
858
859 client
860 }
861
862 #[tokio::test]
863 async fn test_share_cipher_with_password_history() {
864 use bitwarden_test::start_api_mock;
865 use wiremock::{
866 Mock, ResponseTemplate,
867 matchers::{method, path_regex},
868 };
869 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
870 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
871 let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
872
873 let mut cipher_view = test_cipher_view_without_org();
874 if let Some(ref mut login) = cipher_view.login {
875 login.password = Some("original_password_123".to_string());
876 }
877
878 let mock = Mock::given(method("PUT"))
880 .and(path_regex(r"/ciphers/[a-f0-9-]+/share"))
881 .and(wiremock::matchers::body_string_contains("passwordHistory"))
882 .respond_with(move |req: &wiremock::Request| {
883 let body_bytes = req.body.as_slice();
884 let request_body: bitwarden_api_api::models::CipherShareRequestModel =
885 serde_json::from_slice(body_bytes).expect("Failed to parse request body");
886
887 let response = CipherResponseModel {
889 object: Some("cipher".to_string()),
890 id: Some(cipher_id.into()),
891 organization_id: Some(
892 request_body
893 .cipher
894 .organization_id
895 .unwrap()
896 .parse()
897 .unwrap(),
898 ),
899 r#type: request_body.cipher.r#type,
900 name: Some(request_body.cipher.name),
901 notes: request_body.cipher.notes,
902 login: request_body.cipher.login,
903 reprompt: request_body.cipher.reprompt,
904 password_history: request_body.cipher.password_history,
905 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
906 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
907 edit: Some(true),
908 view_password: Some(true),
909 organization_use_totp: Some(true),
910 favorite: request_body.cipher.favorite,
911 fields: request_body.cipher.fields,
912 key: request_body.cipher.key,
913 ..Default::default()
914 };
915
916 ResponseTemplate::new(200).set_body_json(&response)
917 });
918
919 let (mock_server, _config) = start_api_mock(vec![mock]).await;
921 let client = make_test_client_with_wiremock(&mock_server).await;
922 let repository = std::sync::Arc::new(MemoryRepository::<Cipher>::default());
923 let cipher_client = client.vault().ciphers();
924 let original = cipher_view.clone();
925 repository
926 .set(
927 TEST_CIPHER_ID.parse().unwrap(),
928 cipher_client
929 .encrypt(original.clone())
930 .await
931 .unwrap()
932 .cipher,
933 )
934 .await
935 .unwrap();
936
937 client
938 .platform()
939 .state()
940 .register_client_managed(repository.clone());
941
942 if let Some(ref mut login) = cipher_view.login {
944 login.password = Some("new_password_456".to_string());
945 }
946
947 let result = cipher_client
948 .share_cipher(
949 cipher_view.clone(),
950 org_id,
951 vec![collection_id],
952 Some(original),
953 )
954 .await;
955
956 let shared_cipher = result.unwrap();
957 assert_eq!(shared_cipher.organization_id, Some(org_id));
958 let history = shared_cipher.password_history.unwrap();
959 assert_eq!(
960 history.len(),
961 1,
962 "Password history should have 1 entry for the changed password"
963 );
964 assert_eq!(
965 history[0].password, "original_password_123",
966 "Password history should contain the original password"
967 );
968 assert_eq!(
969 shared_cipher.login.as_ref().unwrap().password,
970 Some("new_password_456".to_string()),
971 "New password should be set"
972 );
973 }
974
975 #[tokio::test]
976 async fn test_share_ciphers_bulk_with_password_history() {
977 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
978 let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
979
980 let mut cipher_view1 = test_cipher_view_without_org();
981 cipher_view1.id = Some(TEST_CIPHER_ID.parse().unwrap());
982 if let Some(ref mut login) = cipher_view1.login {
983 login.password = Some("original_password_1".to_string());
984 }
985
986 let mut cipher_view2 = test_cipher_view_without_org();
987 cipher_view2.id = Some("11111111-2222-3333-4444-555555555555".parse().unwrap());
988 if let Some(ref mut login) = cipher_view2.login {
989 login.password = Some("original_password_2".to_string());
990 }
991
992 let mock = Mock::given(method("PUT"))
994 .and(path("/ciphers/share"))
995 .and(wiremock::matchers::body_string_contains("passwordHistory"))
996 .respond_with(move |req: &wiremock::Request| {
997 let body_bytes = req.body.as_slice();
998 let request_body: bitwarden_api_api::models::CipherBulkShareRequestModel =
999 serde_json::from_slice(body_bytes).expect("Failed to parse request body");
1000
1001 let ciphers: Vec<_> = request_body
1003 .ciphers
1004 .into_iter()
1005 .map(
1006 |cipher| bitwarden_api_api::models::CipherMiniResponseModel {
1007 object: Some("cipherMini".to_string()),
1008 id: Some(cipher.id),
1009 organization_id: cipher.organization_id.and_then(|id| id.parse().ok()),
1010 r#type: cipher.r#type,
1011 name: Some(cipher.name),
1012 notes: cipher.notes,
1013 login: cipher.login,
1014 reprompt: cipher.reprompt,
1015 password_history: cipher.password_history,
1016 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
1017 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
1018 organization_use_totp: Some(true),
1019 fields: cipher.fields,
1020 key: cipher.key,
1021 ..Default::default()
1022 },
1023 )
1024 .collect();
1025
1026 let response =
1027 bitwarden_api_api::models::CipherMiniResponseModelListResponseModel {
1028 object: Some("list".to_string()),
1029 data: Some(ciphers),
1030 continuation_token: None,
1031 };
1032
1033 ResponseTemplate::new(200).set_body_json(&response)
1034 });
1035
1036 let (mock_server, _config) = start_api_mock(vec![mock]).await;
1038 let client = make_test_client_with_wiremock(&mock_server).await;
1039 let repository = std::sync::Arc::new(MemoryRepository::<Cipher>::default());
1040 let cipher_client = client.vault().ciphers();
1041
1042 let encrypted_original1 = cipher_client.encrypt(cipher_view1.clone()).await.unwrap();
1043 repository
1044 .set(
1045 encrypted_original1.cipher.id.unwrap(),
1046 encrypted_original1.cipher.clone(),
1047 )
1048 .await
1049 .unwrap();
1050
1051 let encrypted_original2 = cipher_client.encrypt(cipher_view2.clone()).await.unwrap();
1052 repository
1053 .set(
1054 encrypted_original2.cipher.id.unwrap(),
1055 encrypted_original2.cipher.clone(),
1056 )
1057 .await
1058 .unwrap();
1059
1060 client
1061 .platform()
1062 .state()
1063 .register_client_managed(repository.clone());
1064
1065 if let Some(ref mut login) = cipher_view1.login {
1067 login.password = Some("new_password_1".to_string());
1068 }
1069 if let Some(ref mut login) = cipher_view2.login {
1070 login.password = Some("new_password_2".to_string());
1071 }
1072
1073 let result = cipher_client
1074 .share_ciphers_bulk(
1075 vec![cipher_view1, cipher_view2],
1076 org_id,
1077 vec![collection_id],
1078 )
1079 .await;
1080
1081 let shared_ciphers = result.unwrap();
1082 assert_eq!(shared_ciphers.len(), 2);
1083
1084 assert_eq!(
1085 shared_ciphers[0].password_history.clone().unwrap()[0].password,
1086 "original_password_1"
1087 );
1088 assert_eq!(
1089 shared_ciphers[0].login.clone().unwrap().password,
1090 Some("new_password_1".to_string())
1091 );
1092
1093 assert_eq!(
1094 shared_ciphers[1].password_history.clone().unwrap()[0].password,
1095 "original_password_2"
1096 );
1097 assert_eq!(
1098 shared_ciphers[1].login.clone().unwrap().password,
1099 Some("new_password_2".to_string())
1100 );
1101 }
1102}