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