bitwarden_vault/cipher/cipher_client/
share_cipher.rs

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
17/// Standalone function that shares a cipher to an organization via API call.
18/// This function is extracted to allow for easier testing with mocked dependencies.
19async 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
47/// Standalone function that shares multiple ciphers to an organization via API call.
48/// This function is extracted to allow for easier testing with mocked dependencies.
49async 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        // The server does not return the full Cipher object, so we pull the details from the
73        // current local version to fill in those missing values.
74        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    /// Moves a cipher into an organization, adds it to collections, and calls the share_cipher API.
173    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    /// Moves a group of ciphers into an organization, adds them to collections, and calls the
225    /// share_ciphers API.
226    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        // Add an attachment WITHOUT a key - this should cause an error
402        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, // No key!
409            #[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        // Should fail because attachment is missing a key
423        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        // Register a repository with the client so get_repository() works
435        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        // Create multiple ciphers with IDs, one already in org
444        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        // Encrypt and store cipher_view_1 in repository for password history lookup
449        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        // Should fail because one cipher already has an organization
460        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        // Create a minimal encrypted cipher for testing the API logic
481        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        // Use a test user ID from the test accounts
521        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        // Verify the cipher was stored in repository
577        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        // Pre-populate repository with original cipher data that will be used for missing fields
646        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        // Verify the cipher was updated in repository
719        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); // Should preserve from original
727    }
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        // Set up wiremock server with mock that echoes back the request data
833        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                // Echo back the cipher data
842                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        // Set up the client with mocked server and repository.
874        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        // Change the password to make sure password_history is updated.
893        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        // Set up wiremock server with mock that echoes back the request data
944        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                // Echo back the cipher data
953                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        // Set up the client with mocked server and repository.
988        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        // Change the passwords to make sure password_history is updated.
1017        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}