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