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