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