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