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 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: "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 }
572 }
573
574 #[tokio::test]
575 async fn test_share_cipher_api_success() {
576 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
577 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
578 let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
579
580 let api_client = ApiClient::new_mocked(move |mock| {
581 mock.ciphers_api.expect_put_share().returning(move |_id, _body| {
582 Ok(CipherResponseModel {
583 object: Some("cipher".to_string()),
584 id: Some(cipher_id.into()),
585 organization_id: Some(org_id.into()),
586 r#type: Some(bitwarden_api_api::models::CipherType::Login),
587 name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
588 notes: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
589 login: Some(Box::new(bitwarden_api_api::models::CipherLoginModel {
590 username: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
591 password: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
592 ..Default::default()
593 })),
594 reprompt: Some(bitwarden_api_api::models::CipherRepromptType::None),
595 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
596 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
597 edit: Some(true),
598 view_password: Some(true),
599 organization_use_totp: Some(true),
600 favorite: Some(false),
601 ..Default::default()
602 })
603 });
604 });
605
606 let repository = MemoryRepository::<Cipher>::default();
607 let encryption_context = create_encryption_context();
608 let collection_ids: Vec<CollectionId> = vec![collection_id];
609
610 let result = share_cipher(
611 api_client.ciphers_api(),
612 &repository,
613 encryption_context,
614 collection_ids.clone(),
615 )
616 .await;
617
618 assert!(result.is_ok());
619 let shared_cipher = result.unwrap();
620
621 let stored_cipher = repository
623 .get(TEST_CIPHER_ID.parse().unwrap())
624 .await
625 .unwrap()
626 .expect("Cipher should be stored");
627
628 assert_eq!(stored_cipher.id, shared_cipher.id);
629 assert_eq!(
630 stored_cipher
631 .organization_id
632 .as_ref()
633 .map(ToString::to_string),
634 Some(TEST_ORG_ID.to_string())
635 );
636 assert_eq!(stored_cipher.collection_ids, collection_ids);
637 }
638
639 #[tokio::test]
640 async fn test_share_cipher_api_handles_404() {
641 let api_client = ApiClient::new_mocked(|mock| {
642 mock.ciphers_api
643 .expect_put_share()
644 .returning(|_id, _body| Err(std::io::Error::other("Not found").into()));
645 });
646
647 let repository = MemoryRepository::<Cipher>::default();
648 let encryption_context = create_encryption_context();
649 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
650
651 let result = share_cipher(
652 api_client.ciphers_api(),
653 &repository,
654 encryption_context,
655 collection_ids,
656 )
657 .await;
658
659 assert!(result.is_err());
660 }
661
662 #[tokio::test]
663 async fn test_share_ciphers_bulk_api_success() {
664 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
665 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
666
667 let api_client = ApiClient::new_mocked(move |mock| {
668 mock.ciphers_api.expect_put_share_many().returning(move |_body| {
669 Ok(CipherMiniResponseModelListResponseModel {
670 object: Some("list".to_string()),
671 data: Some(vec![bitwarden_api_api::models::CipherMiniResponseModel {
672 object: Some("cipherMini".to_string()),
673 id: Some(cipher_id.into()),
674 organization_id: Some(org_id.into()),
675 r#type: Some(bitwarden_api_api::models::CipherType::Login),
676 name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
677 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
678 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
679 ..Default::default()
680 }]),
681 continuation_token: None,
682 })
683 });
684 });
685
686 let repository = MemoryRepository::<Cipher>::default();
687
688 let original_cipher = Cipher {
690 r#type: CipherType::Login,
691 login: Some(crate::cipher::Login {
692 username: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
693 password: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
694 password_revision_date: None,
695 uris: None,
696 totp: None,
697 autofill_on_page_load: None,
698 fido2_credentials: None,
699 }),
700 id: Some(TEST_CIPHER_ID.parse().unwrap()),
701 organization_id: None,
702 folder_id: None,
703 collection_ids: vec![],
704 key: None,
705 name: "2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap(),
706 notes: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
707 identity: None,
708 card: None,
709 secure_note: None,
710 ssh_key: None,
711 bank_account: None,
712 drivers_license: None,
713 passport: None,
714 favorite: true,
715 reprompt: CipherRepromptType::None,
716 organization_use_totp: true,
717 edit: true,
718 permissions: None,
719 view_password: true,
720 local_data: None,
721 attachments: None,
722 fields: None,
723 password_history: None,
724 creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
725 deleted_date: None,
726 revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
727 archived_date: None,
728 data: None,
729 };
730
731 repository
732 .set(TEST_CIPHER_ID.parse().unwrap(), original_cipher)
733 .await
734 .unwrap();
735
736 let encryption_context = create_encryption_context();
737 let collection_ids: Vec<CollectionId> = vec![
738 TEST_COLLECTION_ID_1.parse().unwrap(),
739 TEST_COLLECTION_ID_2.parse().unwrap(),
740 ];
741
742 let result = share_ciphers_bulk(
743 api_client.ciphers_api(),
744 &repository,
745 vec![encryption_context],
746 collection_ids.clone(),
747 )
748 .await;
749
750 assert!(result.is_ok());
751 let shared_ciphers = result.unwrap();
752 assert_eq!(shared_ciphers.len(), 1);
753
754 let shared_cipher = &shared_ciphers[0];
755 assert_eq!(
756 shared_cipher
757 .organization_id
758 .as_ref()
759 .map(ToString::to_string),
760 Some(TEST_ORG_ID.to_string())
761 );
762 assert_eq!(shared_cipher.collection_ids, collection_ids);
763
764 let stored_cipher = repository
766 .get(TEST_CIPHER_ID.parse().unwrap())
767 .await
768 .unwrap()
769 .expect("Cipher should be stored");
770
771 assert_eq!(stored_cipher.id, shared_cipher.id);
772 assert!(stored_cipher.favorite); }
774
775 #[tokio::test]
776 async fn test_share_ciphers_bulk_api_handles_error() {
777 let api_client = ApiClient::new_mocked(|mock| {
778 mock.ciphers_api
779 .expect_put_share_many()
780 .returning(|_body| Err(std::io::Error::other("Server error").into()));
781 });
782
783 let repository = MemoryRepository::<Cipher>::default();
784 let encryption_context = create_encryption_context();
785 let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
786
787 let result = share_ciphers_bulk(
788 api_client.ciphers_api(),
789 &repository,
790 vec![encryption_context],
791 collection_ids,
792 )
793 .await;
794
795 assert!(result.is_err());
796 }
797
798 async fn make_test_client_with_wiremock(mock_server: &wiremock::MockServer) -> Client {
799 use bitwarden_core::{
800 ClientSettings, DeviceType, UserId,
801 key_management::crypto::{
802 InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest,
803 },
804 };
805 use bitwarden_crypto::{EncString, Kdf};
806
807 let settings = ClientSettings {
808 identity_url: format!("http://{}", mock_server.address()),
809 api_url: format!("http://{}", mock_server.address()),
810 user_agent: "Bitwarden Test".into(),
811 device_type: DeviceType::SDK,
812 device_identifier: None,
813 bitwarden_client_version: None,
814 bitwarden_package_type: None,
815 };
816
817 let client = Client::new_test(Some(settings));
818
819 client
820 .internal
821 .load_flags(std::collections::HashMap::from([(
822 "enableCipherKeyEncryption".to_owned(),
823 true,
824 )]))
825 .await;
826
827 let user_request = InitUserCryptoRequest {
828 user_id: Some(UserId::new(uuid::uuid!("060000fb-0922-4dd3-b170-6e15cb5df8c8"))),
829 kdf_params: Kdf::PBKDF2 {
830 iterations: 600_000.try_into().unwrap(),
831 },
832 email: "[email protected]".to_owned(),
833 account_cryptographic_state: WrappedAccountCryptographicState::V1 {
834 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(),
835 },
836 method: InitUserCryptoMethod::MasterPasswordUnlock {
837 password: "asdfasdfasdf".to_owned(),
838 master_password_unlock: MasterPasswordUnlockData {
839 kdf: Kdf::PBKDF2 {
840 iterations: 600_000.try_into().unwrap(),
841 },
842 master_key_wrapped_user_key: "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=".parse().unwrap(),
843 salt: "[email protected]".to_owned(),
844 },
845 },
846 upgrade_token: None,
847 };
848
849 let org_request = InitOrgCryptoRequest {
850 organization_keys: std::collections::HashMap::from([(
851 TEST_ORG_ID.parse().unwrap(),
852 "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==".parse().unwrap()
853 )])
854 };
855
856 client
857 .crypto()
858 .initialize_user_crypto(user_request)
859 .await
860 .unwrap();
861 client
862 .crypto()
863 .initialize_org_crypto(org_request)
864 .await
865 .unwrap();
866
867 client
868 }
869
870 #[tokio::test]
871 async fn test_share_cipher_with_password_history() {
872 use bitwarden_test::start_api_mock;
873 use wiremock::{
874 Mock, ResponseTemplate,
875 matchers::{method, path_regex},
876 };
877 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
878 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
879 let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
880
881 let mut cipher_view = test_cipher_view_without_org();
882 if let Some(ref mut login) = cipher_view.login {
883 login.password = Some("original_password_123".to_string());
884 }
885
886 let mock = Mock::given(method("PUT"))
888 .and(path_regex(r"/ciphers/[a-f0-9-]+/share"))
889 .and(wiremock::matchers::body_string_contains("passwordHistory"))
890 .respond_with(move |req: &wiremock::Request| {
891 let body_bytes = req.body.as_slice();
892 let request_body: bitwarden_api_api::models::CipherShareRequestModel =
893 serde_json::from_slice(body_bytes).expect("Failed to parse request body");
894
895 let response = CipherResponseModel {
897 object: Some("cipher".to_string()),
898 id: Some(cipher_id.into()),
899 organization_id: Some(
900 request_body
901 .cipher
902 .organization_id
903 .unwrap()
904 .parse()
905 .unwrap(),
906 ),
907 r#type: request_body.cipher.r#type,
908 name: Some(request_body.cipher.name),
909 notes: request_body.cipher.notes,
910 login: request_body.cipher.login,
911 reprompt: request_body.cipher.reprompt,
912 password_history: request_body.cipher.password_history,
913 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
914 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
915 edit: Some(true),
916 view_password: Some(true),
917 organization_use_totp: Some(true),
918 favorite: request_body.cipher.favorite,
919 fields: request_body.cipher.fields,
920 key: request_body.cipher.key,
921 ..Default::default()
922 };
923
924 ResponseTemplate::new(200).set_body_json(&response)
925 });
926
927 let (mock_server, _config) = start_api_mock(vec![mock]).await;
929 let client = make_test_client_with_wiremock(&mock_server).await;
930 let repository = std::sync::Arc::new(MemoryRepository::<Cipher>::default());
931 let cipher_client = client.vault().ciphers();
932 let original = cipher_view.clone();
933 repository
934 .set(
935 TEST_CIPHER_ID.parse().unwrap(),
936 cipher_client
937 .encrypt(original.clone())
938 .await
939 .unwrap()
940 .cipher,
941 )
942 .await
943 .unwrap();
944
945 client
946 .platform()
947 .state()
948 .register_client_managed(repository.clone());
949
950 if let Some(ref mut login) = cipher_view.login {
952 login.password = Some("new_password_456".to_string());
953 }
954
955 let result = cipher_client
956 .share_cipher(
957 cipher_view.clone(),
958 org_id,
959 vec![collection_id],
960 Some(original),
961 )
962 .await;
963
964 let shared_cipher = result.unwrap();
965 assert_eq!(shared_cipher.organization_id, Some(org_id));
966 let history = shared_cipher.password_history.unwrap();
967 assert_eq!(
968 history.len(),
969 1,
970 "Password history should have 1 entry for the changed password"
971 );
972 assert_eq!(
973 history[0].password, "original_password_123",
974 "Password history should contain the original password"
975 );
976 assert_eq!(
977 shared_cipher.login.as_ref().unwrap().password,
978 Some("new_password_456".to_string()),
979 "New password should be set"
980 );
981 }
982
983 #[tokio::test]
984 async fn test_share_ciphers_bulk_with_password_history() {
985 let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
986 let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
987
988 let mut cipher_view1 = test_cipher_view_without_org();
989 cipher_view1.id = Some(TEST_CIPHER_ID.parse().unwrap());
990 if let Some(ref mut login) = cipher_view1.login {
991 login.password = Some("original_password_1".to_string());
992 }
993
994 let mut cipher_view2 = test_cipher_view_without_org();
995 cipher_view2.id = Some("11111111-2222-3333-4444-555555555555".parse().unwrap());
996 if let Some(ref mut login) = cipher_view2.login {
997 login.password = Some("original_password_2".to_string());
998 }
999
1000 let mock = Mock::given(method("PUT"))
1002 .and(path("/ciphers/share"))
1003 .and(wiremock::matchers::body_string_contains("passwordHistory"))
1004 .respond_with(move |req: &wiremock::Request| {
1005 let body_bytes = req.body.as_slice();
1006 let request_body: bitwarden_api_api::models::CipherBulkShareRequestModel =
1007 serde_json::from_slice(body_bytes).expect("Failed to parse request body");
1008
1009 let ciphers: Vec<_> = request_body
1011 .ciphers
1012 .into_iter()
1013 .map(
1014 |cipher| bitwarden_api_api::models::CipherMiniResponseModel {
1015 object: Some("cipherMini".to_string()),
1016 id: Some(cipher.id),
1017 organization_id: cipher.organization_id.and_then(|id| id.parse().ok()),
1018 r#type: cipher.r#type,
1019 name: Some(cipher.name),
1020 notes: cipher.notes,
1021 login: cipher.login,
1022 reprompt: cipher.reprompt,
1023 password_history: cipher.password_history,
1024 revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
1025 creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
1026 organization_use_totp: Some(true),
1027 fields: cipher.fields,
1028 key: cipher.key,
1029 ..Default::default()
1030 },
1031 )
1032 .collect();
1033
1034 let response =
1035 bitwarden_api_api::models::CipherMiniResponseModelListResponseModel {
1036 object: Some("list".to_string()),
1037 data: Some(ciphers),
1038 continuation_token: None,
1039 };
1040
1041 ResponseTemplate::new(200).set_body_json(&response)
1042 });
1043
1044 let (mock_server, _config) = start_api_mock(vec![mock]).await;
1046 let client = make_test_client_with_wiremock(&mock_server).await;
1047 let repository = std::sync::Arc::new(MemoryRepository::<Cipher>::default());
1048 let cipher_client = client.vault().ciphers();
1049
1050 let encrypted_original1 = cipher_client.encrypt(cipher_view1.clone()).await.unwrap();
1051 repository
1052 .set(
1053 encrypted_original1.cipher.id.unwrap(),
1054 encrypted_original1.cipher.clone(),
1055 )
1056 .await
1057 .unwrap();
1058
1059 let encrypted_original2 = cipher_client.encrypt(cipher_view2.clone()).await.unwrap();
1060 repository
1061 .set(
1062 encrypted_original2.cipher.id.unwrap(),
1063 encrypted_original2.cipher.clone(),
1064 )
1065 .await
1066 .unwrap();
1067
1068 client
1069 .platform()
1070 .state()
1071 .register_client_managed(repository.clone());
1072
1073 if let Some(ref mut login) = cipher_view1.login {
1075 login.password = Some("new_password_1".to_string());
1076 }
1077 if let Some(ref mut login) = cipher_view2.login {
1078 login.password = Some("new_password_2".to_string());
1079 }
1080
1081 let result = cipher_client
1082 .share_ciphers_bulk(
1083 vec![cipher_view1, cipher_view2],
1084 org_id,
1085 vec![collection_id],
1086 )
1087 .await;
1088
1089 let shared_ciphers = result.unwrap();
1090 assert_eq!(shared_ciphers.len(), 2);
1091
1092 assert_eq!(
1093 shared_ciphers[0].password_history.clone().unwrap()[0].password,
1094 "original_password_1"
1095 );
1096 assert_eq!(
1097 shared_ciphers[0].login.clone().unwrap().password,
1098 Some("new_password_1".to_string())
1099 );
1100
1101 assert_eq!(
1102 shared_ciphers[1].password_history.clone().unwrap()[0].password,
1103 "original_password_2"
1104 );
1105 assert_eq!(
1106 shared_ciphers[1].login.clone().unwrap().password,
1107 Some("new_password_2".to_string())
1108 );
1109 }
1110}