Skip to main content

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