bitwarden_vault/cipher/cipher_client/
share_cipher.rs

1use bitwarden_api_api::{
2    apis::ciphers_api::CiphersApi,
3    models::{CipherBulkShareRequestModel, CipherShareRequestModel},
4};
5use bitwarden_collections::collection::CollectionId;
6use bitwarden_core::{MissingFieldError, OrganizationId, require};
7use bitwarden_crypto::EncString;
8use bitwarden_state::repository::Repository;
9#[cfg(feature = "wasm")]
10use wasm_bindgen::prelude::wasm_bindgen;
11
12use crate::{
13    Cipher, CipherError, CipherId, CipherRepromptType, CipherView, CiphersClient,
14    EncryptionContext, VaultParseError,
15};
16
17/// Standalone function that shares a cipher to an organization via API call.
18/// This function is extracted to allow for easier testing with mocked dependencies.
19async fn share_cipher(
20    api_client: &dyn CiphersApi,
21    repository: &dyn Repository<Cipher>,
22    encrypted_cipher: EncryptionContext,
23    collection_ids: Vec<CollectionId>,
24) -> Result<Cipher, CipherError> {
25    let cipher_id: uuid::Uuid = require!(encrypted_cipher.cipher.id).into();
26
27    let req = CipherShareRequestModel::new(
28        collection_ids
29            .iter()
30            .map(<CollectionId as ToString>::to_string)
31            .collect(),
32        encrypted_cipher.into(),
33    );
34
35    let response = api_client.put_share(cipher_id, Some(req)).await?;
36
37    let mut new_cipher: Cipher = response.try_into()?;
38    new_cipher.collection_ids = collection_ids;
39
40    repository
41        .set(cipher_id.to_string(), new_cipher.clone())
42        .await?;
43
44    Ok(new_cipher)
45}
46
47/// Standalone function that shares multiple ciphers to an organization via API call.
48/// This function is extracted to allow for easier testing with mocked dependencies.
49async fn share_ciphers_bulk(
50    api_client: &dyn CiphersApi,
51    repository: &dyn Repository<Cipher>,
52    encrypted_ciphers: Vec<EncryptionContext>,
53    collection_ids: Vec<CollectionId>,
54) -> Result<Vec<Cipher>, CipherError> {
55    let request = CipherBulkShareRequestModel::new(
56        collection_ids
57            .iter()
58            .map(<CollectionId as ToString>::to_string)
59            .collect(),
60        encrypted_ciphers
61            .into_iter()
62            .map(|ec| ec.try_into())
63            .collect::<Result<Vec<_>, _>>()?,
64    );
65
66    let response = api_client.put_share_many(Some(request)).await?;
67
68    let cipher_minis = response.data.unwrap_or_default();
69    let mut results = Vec::new();
70
71    for cipher_mini in cipher_minis {
72        // The server does not return the full Cipher object, so we pull the details from the
73        // current local version to fill in those missing values.
74        let orig_cipher = repository
75            .get(cipher_mini.id.ok_or(MissingFieldError("id"))?.to_string())
76            .await?;
77
78        let cipher: Cipher = Cipher {
79            id: cipher_mini.id.map(CipherId::new),
80            organization_id: cipher_mini.organization_id.map(OrganizationId::new),
81            key: EncString::try_from_optional(cipher_mini.key)?,
82            name: require!(EncString::try_from_optional(cipher_mini.name)?),
83            notes: EncString::try_from_optional(cipher_mini.notes)?,
84            r#type: require!(cipher_mini.r#type).try_into()?,
85            login: cipher_mini.login.map(|l| (*l).try_into()).transpose()?,
86            identity: cipher_mini.identity.map(|i| (*i).try_into()).transpose()?,
87            card: cipher_mini.card.map(|c| (*c).try_into()).transpose()?,
88            secure_note: cipher_mini
89                .secure_note
90                .map(|s| (*s).try_into())
91                .transpose()?,
92            ssh_key: cipher_mini.ssh_key.map(|s| (*s).try_into()).transpose()?,
93            reprompt: cipher_mini
94                .reprompt
95                .map(|r| r.try_into())
96                .transpose()?
97                .unwrap_or(CipherRepromptType::None),
98            organization_use_totp: cipher_mini.organization_use_totp.unwrap_or(true),
99            attachments: cipher_mini
100                .attachments
101                .map(|a| a.into_iter().map(|a| a.try_into()).collect())
102                .transpose()?,
103            fields: cipher_mini
104                .fields
105                .map(|f| f.into_iter().map(|f| f.try_into()).collect())
106                .transpose()?,
107            password_history: cipher_mini
108                .password_history
109                .map(|p| p.into_iter().map(|p| p.try_into()).collect())
110                .transpose()?,
111            creation_date: require!(cipher_mini.creation_date)
112                .parse()
113                .map_err(Into::<VaultParseError>::into)?,
114            deleted_date: cipher_mini
115                .deleted_date
116                .map(|d| d.parse())
117                .transpose()
118                .map_err(Into::<VaultParseError>::into)?,
119            revision_date: require!(cipher_mini.revision_date)
120                .parse()
121                .map_err(Into::<VaultParseError>::into)?,
122            archived_date: orig_cipher
123                .as_ref()
124                .map(|c| c.archived_date)
125                .unwrap_or_default(),
126            edit: orig_cipher.as_ref().map(|c| c.edit).unwrap_or_default(),
127            favorite: orig_cipher.as_ref().map(|c| c.favorite).unwrap_or_default(),
128            folder_id: orig_cipher
129                .as_ref()
130                .map(|c| c.folder_id)
131                .unwrap_or_default(),
132            permissions: orig_cipher
133                .as_ref()
134                .map(|c| c.permissions)
135                .unwrap_or_default(),
136            view_password: orig_cipher
137                .as_ref()
138                .map(|c| c.view_password)
139                .unwrap_or_default(),
140            local_data: orig_cipher.map(|c| c.local_data).unwrap_or_default(),
141            collection_ids: collection_ids.clone(),
142            data: None,
143        };
144
145        repository
146            .set(require!(cipher.id).to_string(), cipher.clone())
147            .await?;
148        results.push(cipher)
149    }
150
151    Ok(results)
152}
153
154#[cfg_attr(feature = "wasm", wasm_bindgen)]
155impl CiphersClient {
156    fn update_organization_and_collections(
157        &self,
158        mut cipher_view: CipherView,
159        organization_id: OrganizationId,
160        collection_ids: Vec<CollectionId>,
161    ) -> Result<CipherView, CipherError> {
162        let organization_id = &organization_id;
163        if cipher_view.organization_id.is_some() {
164            return Err(CipherError::OrganizationAlreadySet);
165        }
166
167        cipher_view = self.move_to_organization(cipher_view, *organization_id)?;
168        cipher_view.collection_ids = collection_ids;
169        Ok(cipher_view)
170    }
171
172    /// Moves a cipher into an organization, adds it to collections, and calls the share_cipher API.
173    pub async fn share_cipher(
174        &self,
175        mut cipher_view: CipherView,
176        organization_id: OrganizationId,
177        collection_ids: Vec<CollectionId>,
178        original_cipher: Option<Cipher>,
179    ) -> Result<Cipher, CipherError> {
180        cipher_view = self.update_organization_and_collections(
181            cipher_view,
182            organization_id,
183            collection_ids.clone(),
184        )?;
185
186        self.update_password_history(&mut cipher_view, original_cipher)
187            .await?;
188
189        let encrypted_cipher = self.encrypt(cipher_view)?;
190
191        let api_client = &self
192            .client
193            .internal
194            .get_api_configurations()
195            .await
196            .api_client;
197
198        share_cipher(
199            api_client.ciphers_api(),
200            &*self.get_repository()?,
201            encrypted_cipher,
202            collection_ids,
203        )
204        .await
205    }
206
207    async fn update_password_history(
208        &self,
209        cipher_view: &mut CipherView,
210        mut original_cipher: Option<Cipher>,
211    ) -> Result<(), CipherError> {
212        if let (Some(cipher_id), None) = (cipher_view.id, &original_cipher) {
213            original_cipher = self.get_repository()?.get(cipher_id.to_string()).await?;
214        }
215        if let Some(original_cipher_view) = original_cipher
216            .map(|cipher| self.decrypt(cipher))
217            .transpose()?
218        {
219            cipher_view.update_password_history(&original_cipher_view);
220        }
221        Ok(())
222    }
223
224    async fn prepare_encrypted_ciphers_for_bulk_share(
225        &self,
226        cipher_views: Vec<CipherView>,
227        organization_id: OrganizationId,
228        collection_ids: Vec<CollectionId>,
229    ) -> Result<Vec<EncryptionContext>, CipherError> {
230        let mut encrypted_ciphers: Vec<EncryptionContext> = Vec::new();
231        for mut cv in cipher_views {
232            cv = self.update_organization_and_collections(
233                cv,
234                organization_id,
235                collection_ids.clone(),
236            )?;
237            self.update_password_history(&mut cv, None).await?;
238            encrypted_ciphers.push(self.encrypt(cv)?);
239        }
240        Ok(encrypted_ciphers)
241    }
242
243    #[cfg(feature = "uniffi")]
244    /// Prepares ciphers for bulk sharing by assigning them to an organization, adding them to
245    /// collections, updating password history, and encrypting them. This method is exposed for
246    /// UniFFI bindings. Can be removed once Mobile supports authenticated API calls via the SDK.
247    pub async fn prepare_ciphers_for_bulk_share(
248        &self,
249        cipher_views: Vec<CipherView>,
250        organization_id: OrganizationId,
251        collection_ids: Vec<CollectionId>,
252    ) -> Result<Vec<EncryptionContext>, CipherError> {
253        self.prepare_encrypted_ciphers_for_bulk_share(cipher_views, organization_id, collection_ids)
254            .await
255    }
256
257    /// Moves a group of ciphers into an organization, adds them to collections, and calls the
258    /// share_ciphers API.
259    pub async fn share_ciphers_bulk(
260        &self,
261        cipher_views: Vec<CipherView>,
262        organization_id: OrganizationId,
263        collection_ids: Vec<CollectionId>,
264    ) -> Result<Vec<Cipher>, CipherError> {
265        let encrypted_ciphers = self
266            .prepare_encrypted_ciphers_for_bulk_share(
267                cipher_views,
268                organization_id,
269                collection_ids.clone(),
270            )
271            .await?;
272
273        let api_client = &self
274            .client
275            .internal
276            .get_api_configurations()
277            .await
278            .api_client;
279
280        share_ciphers_bulk(
281            api_client.ciphers_api(),
282            &*self.get_repository()?,
283            encrypted_ciphers,
284            collection_ids,
285        )
286        .await
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use bitwarden_api_api::{
293        apis::ApiClient,
294        models::{CipherMiniResponseModelListResponseModel, CipherResponseModel},
295    };
296    use bitwarden_core::{
297        Client,
298        client::test_accounts::test_bitwarden_com_account,
299        key_management::{
300            MasterPasswordUnlockData, account_cryptographic_state::WrappedAccountCryptographicState,
301        },
302    };
303    use bitwarden_test::{MemoryRepository, start_api_mock};
304    use wiremock::{
305        Mock, ResponseTemplate,
306        matchers::{method, path},
307    };
308
309    use super::*;
310    use crate::{CipherRepromptType, CipherType, LoginView, VaultClientExt};
311
312    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
313    const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8";
314    const TEST_COLLECTION_ID_1: &str = "c1111111-1111-1111-1111-111111111111";
315    const TEST_COLLECTION_ID_2: &str = "c2222222-2222-2222-2222-222222222222";
316
317    fn test_cipher_view_without_org() -> CipherView {
318        CipherView {
319            r#type: CipherType::Login,
320            login: Some(LoginView {
321                username: Some("[email protected]".to_string()),
322                password: Some("password123".to_string()),
323                password_revision_date: None,
324                uris: None,
325                totp: None,
326                autofill_on_page_load: None,
327                fido2_credentials: None,
328            }),
329            id: Some(TEST_CIPHER_ID.parse().unwrap()),
330            organization_id: None,
331            folder_id: None,
332            collection_ids: vec![],
333            key: None,
334            name: "My test login".to_string(),
335            notes: Some("Test notes".to_string()),
336            identity: None,
337            card: None,
338            secure_note: None,
339            ssh_key: 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()).unwrap();
484        let repository = cipher_client.get_repository().unwrap();
485        repository
486            .set(TEST_CIPHER_ID.to_string(), 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                favorite: false,
538                reprompt: CipherRepromptType::None,
539                organization_use_totp: true,
540                edit: true,
541                permissions: None,
542                view_password: true,
543                local_data: None,
544                attachments: None,
545                fields: None,
546                password_history: None,
547                creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
548                deleted_date: None,
549                revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
550                archived_date: None,
551                data: None,
552            };
553
554        // Use a test user ID from the test accounts
555        let user_id: UserId = "00000000-0000-0000-0000-000000000000".parse().unwrap();
556
557        EncryptionContext {
558            cipher,
559            encrypted_for: user_id,
560        }
561    }
562
563    #[tokio::test]
564    async fn test_share_cipher_api_success() {
565        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
566        let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
567        let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
568
569        let api_client = ApiClient::new_mocked(move |mock| {
570            mock.ciphers_api.expect_put_share().returning(move |_id, _body| {
571                Ok(CipherResponseModel {
572                    object: Some("cipher".to_string()),
573                    id: Some(cipher_id.into()),
574                    organization_id: Some(org_id.into()),
575                    r#type: Some(bitwarden_api_api::models::CipherType::Login),
576                    name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
577                    notes: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
578                    login: Some(Box::new(bitwarden_api_api::models::CipherLoginModel {
579                        username: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
580                        password: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
581                        ..Default::default()
582                    })),
583                    reprompt: Some(bitwarden_api_api::models::CipherRepromptType::None),
584                    revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
585                    creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
586                    edit: Some(true),
587                    view_password: Some(true),
588                    organization_use_totp: Some(true),
589                    favorite: Some(false),
590                    ..Default::default()
591                })
592            });
593        });
594
595        let repository = MemoryRepository::<Cipher>::default();
596        let encryption_context = create_encryption_context();
597        let collection_ids: Vec<CollectionId> = vec![collection_id];
598
599        let result = share_cipher(
600            api_client.ciphers_api(),
601            &repository,
602            encryption_context,
603            collection_ids.clone(),
604        )
605        .await;
606
607        assert!(result.is_ok());
608        let shared_cipher = result.unwrap();
609
610        // Verify the cipher was stored in repository
611        let stored_cipher = repository
612            .get(TEST_CIPHER_ID.to_string())
613            .await
614            .unwrap()
615            .expect("Cipher should be stored");
616
617        assert_eq!(stored_cipher.id, shared_cipher.id);
618        assert_eq!(
619            stored_cipher
620                .organization_id
621                .as_ref()
622                .map(ToString::to_string),
623            Some(TEST_ORG_ID.to_string())
624        );
625        assert_eq!(stored_cipher.collection_ids, collection_ids);
626    }
627
628    #[tokio::test]
629    async fn test_share_cipher_api_handles_404() {
630        let api_client = ApiClient::new_mocked(|mock| {
631            mock.ciphers_api.expect_put_share().returning(|_id, _body| {
632                Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
633                    "Not found",
634                )))
635            });
636        });
637
638        let repository = MemoryRepository::<Cipher>::default();
639        let encryption_context = create_encryption_context();
640        let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
641
642        let result = share_cipher(
643            api_client.ciphers_api(),
644            &repository,
645            encryption_context,
646            collection_ids,
647        )
648        .await;
649
650        assert!(result.is_err());
651    }
652
653    #[tokio::test]
654    async fn test_share_ciphers_bulk_api_success() {
655        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
656        let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
657
658        let api_client = ApiClient::new_mocked(move |mock| {
659            mock.ciphers_api.expect_put_share_many().returning(move |_body| {
660                Ok(CipherMiniResponseModelListResponseModel {
661                    object: Some("list".to_string()),
662                    data: Some(vec![bitwarden_api_api::models::CipherMiniResponseModel {
663                        object: Some("cipherMini".to_string()),
664                        id: Some(cipher_id.into()),
665                        organization_id: Some(org_id.into()),
666                        r#type: Some(bitwarden_api_api::models::CipherType::Login),
667                        name: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".to_string()),
668                        revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
669                        creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
670                        ..Default::default()
671                    }]),
672                    continuation_token: None,
673                })
674            });
675        });
676
677        let repository = MemoryRepository::<Cipher>::default();
678
679        // Pre-populate repository with original cipher data that will be used for missing fields
680        let original_cipher = Cipher {
681                r#type: CipherType::Login,
682                login: Some(crate::cipher::Login {
683                    username: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
684                    password: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
685                    password_revision_date: None,
686                    uris: None,
687                    totp: None,
688                    autofill_on_page_load: None,
689                    fido2_credentials: None,
690                }),
691                id: Some(TEST_CIPHER_ID.parse().unwrap()),
692                organization_id: None,
693                folder_id: None,
694                collection_ids: vec![],
695                key: None,
696                name: "2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap(),
697                notes: Some("2.EI9Km5BfrIqBa1W+WCccfA==|laWxNnx+9H3MZww4zm7cBSLisjpi81zreaQntRhegVI=|x42+qKFf5ga6DIL0OW5pxCdLrC/gm8CXJvf3UASGteI=".parse().unwrap()),
698                identity: None,
699                card: None,
700                secure_note: None,
701                ssh_key: None,
702                favorite: true,
703                reprompt: CipherRepromptType::None,
704                organization_use_totp: true,
705                edit: true,
706                permissions: None,
707                view_password: true,
708                local_data: None,
709                attachments: None,
710                fields: None,
711                password_history: None,
712                creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
713                deleted_date: None,
714                revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
715                archived_date: None,
716                data: None,
717            };
718
719        repository
720            .set(TEST_CIPHER_ID.to_string(), original_cipher)
721            .await
722            .unwrap();
723
724        let encryption_context = create_encryption_context();
725        let collection_ids: Vec<CollectionId> = vec![
726            TEST_COLLECTION_ID_1.parse().unwrap(),
727            TEST_COLLECTION_ID_2.parse().unwrap(),
728        ];
729
730        let result = share_ciphers_bulk(
731            api_client.ciphers_api(),
732            &repository,
733            vec![encryption_context],
734            collection_ids.clone(),
735        )
736        .await;
737
738        assert!(result.is_ok());
739        let shared_ciphers = result.unwrap();
740        assert_eq!(shared_ciphers.len(), 1);
741
742        let shared_cipher = &shared_ciphers[0];
743        assert_eq!(
744            shared_cipher
745                .organization_id
746                .as_ref()
747                .map(ToString::to_string),
748            Some(TEST_ORG_ID.to_string())
749        );
750        assert_eq!(shared_cipher.collection_ids, collection_ids);
751
752        // Verify the cipher was updated in repository
753        let stored_cipher = repository
754            .get(TEST_CIPHER_ID.to_string())
755            .await
756            .unwrap()
757            .expect("Cipher should be stored");
758
759        assert_eq!(stored_cipher.id, shared_cipher.id);
760        assert!(stored_cipher.favorite); // Should preserve from original
761    }
762
763    #[tokio::test]
764    async fn test_share_ciphers_bulk_api_handles_error() {
765        let api_client = ApiClient::new_mocked(|mock| {
766            mock.ciphers_api.expect_put_share_many().returning(|_body| {
767                Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
768                    "Server error",
769                )))
770            });
771        });
772
773        let repository = MemoryRepository::<Cipher>::default();
774        let encryption_context = create_encryption_context();
775        let collection_ids: Vec<CollectionId> = vec![TEST_COLLECTION_ID_1.parse().unwrap()];
776
777        let result = share_ciphers_bulk(
778            api_client.ciphers_api(),
779            &repository,
780            vec![encryption_context],
781            collection_ids,
782        )
783        .await;
784
785        assert!(result.is_err());
786    }
787
788    async fn make_test_client_with_wiremock(mock_server: &wiremock::MockServer) -> Client {
789        use bitwarden_core::{
790            ClientSettings, DeviceType, UserId,
791            key_management::crypto::{
792                InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest,
793            },
794        };
795        use bitwarden_crypto::{EncString, Kdf};
796
797        let settings = ClientSettings {
798            identity_url: format!("http://{}", mock_server.address()),
799            api_url: format!("http://{}", mock_server.address()),
800            user_agent: "Bitwarden Test".into(),
801            device_type: DeviceType::SDK,
802            device_identifier: None,
803            bitwarden_client_version: None,
804            bitwarden_package_type: None,
805        };
806
807        let client = Client::new(Some(settings));
808
809        client
810            .internal
811            .load_flags(std::collections::HashMap::from([(
812                "enableCipherKeyEncryption".to_owned(),
813                true,
814            )]));
815
816        let user_request = InitUserCryptoRequest {
817            user_id: Some(UserId::new(uuid::uuid!("060000fb-0922-4dd3-b170-6e15cb5df8c8"))),
818            kdf_params: Kdf::PBKDF2 {
819                iterations: 600_000.try_into().unwrap(),
820            },
821            email: "[email protected]".to_owned(),
822            account_cryptographic_state: WrappedAccountCryptographicState::V1 {
823                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(),
824            },
825            method: InitUserCryptoMethod::MasterPasswordUnlock {
826                password: "asdfasdfasdf".to_owned(),
827                master_password_unlock: MasterPasswordUnlockData {
828                    kdf: Kdf::PBKDF2 {
829                        iterations: 600_000.try_into().unwrap(),
830                    },
831                    master_key_wrapped_user_key: "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=".parse().unwrap(),
832                    salt: "[email protected]".to_owned(),
833                },
834            },
835        };
836
837        let org_request = InitOrgCryptoRequest {
838            organization_keys: std::collections::HashMap::from([(
839                TEST_ORG_ID.parse().unwrap(),
840                "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==".parse().unwrap()
841            )])
842        };
843
844        client
845            .crypto()
846            .initialize_user_crypto(user_request)
847            .await
848            .unwrap();
849        client
850            .crypto()
851            .initialize_org_crypto(org_request)
852            .await
853            .unwrap();
854
855        client
856    }
857
858    #[tokio::test]
859    async fn test_share_cipher_with_password_history() {
860        use bitwarden_test::start_api_mock;
861        use wiremock::{
862            Mock, ResponseTemplate,
863            matchers::{method, path_regex},
864        };
865        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
866        let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
867        let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
868
869        let mut cipher_view = test_cipher_view_without_org();
870        if let Some(ref mut login) = cipher_view.login {
871            login.password = Some("original_password_123".to_string());
872        }
873
874        // Set up wiremock server with mock that echoes back the request data
875        let mock = Mock::given(method("PUT"))
876            .and(path_regex(r"/ciphers/[a-f0-9-]+/share"))
877            .and(wiremock::matchers::body_string_contains("passwordHistory"))
878            .respond_with(move |req: &wiremock::Request| {
879                let body_bytes = req.body.as_slice();
880                let request_body: bitwarden_api_api::models::CipherShareRequestModel =
881                    serde_json::from_slice(body_bytes).expect("Failed to parse request body");
882
883                // Echo back the cipher data
884                let response = CipherResponseModel {
885                    object: Some("cipher".to_string()),
886                    id: Some(cipher_id.into()),
887                    organization_id: Some(
888                        request_body
889                            .cipher
890                            .organization_id
891                            .unwrap()
892                            .parse()
893                            .unwrap(),
894                    ),
895                    r#type: request_body.cipher.r#type,
896                    name: Some(request_body.cipher.name),
897                    notes: request_body.cipher.notes,
898                    login: request_body.cipher.login,
899                    reprompt: request_body.cipher.reprompt,
900                    password_history: request_body.cipher.password_history,
901                    revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
902                    creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
903                    edit: Some(true),
904                    view_password: Some(true),
905                    organization_use_totp: Some(true),
906                    favorite: request_body.cipher.favorite,
907                    fields: request_body.cipher.fields,
908                    key: request_body.cipher.key,
909                    ..Default::default()
910                };
911
912                ResponseTemplate::new(200).set_body_json(&response)
913            });
914
915        // Set up the client with mocked server and repository.
916        let (mock_server, _config) = start_api_mock(vec![mock]).await;
917        let client = make_test_client_with_wiremock(&mock_server).await;
918        let repository = std::sync::Arc::new(MemoryRepository::<Cipher>::default());
919        let cipher_client = client.vault().ciphers();
920        let encrypted_original = cipher_client.encrypt(cipher_view.clone()).unwrap();
921        repository
922            .set(
923                TEST_CIPHER_ID.to_string(),
924                encrypted_original.cipher.clone(),
925            )
926            .await
927            .unwrap();
928
929        client
930            .platform()
931            .state()
932            .register_client_managed(repository.clone());
933
934        // Change the password to make sure password_history is updated.
935        if let Some(ref mut login) = cipher_view.login {
936            login.password = Some("new_password_456".to_string());
937        }
938
939        let result = cipher_client
940            .share_cipher(
941                cipher_view.clone(),
942                org_id,
943                vec![collection_id],
944                Some(encrypted_original.cipher),
945            )
946            .await;
947
948        let shared_cipher = result.unwrap();
949        assert_eq!(shared_cipher.organization_id, Some(org_id));
950        let decrypted_view = cipher_client.decrypt(shared_cipher.clone()).unwrap();
951        let history = decrypted_view.password_history.unwrap();
952        assert_eq!(
953            history.len(),
954            1,
955            "Password history should have 1 entry for the changed password"
956        );
957        assert_eq!(
958            history[0].password, "original_password_123",
959            "Password history should contain the original password"
960        );
961        assert_eq!(
962            decrypted_view.login.as_ref().unwrap().password,
963            Some("new_password_456".to_string()),
964            "New password should be set"
965        );
966    }
967
968    #[tokio::test]
969    async fn test_share_ciphers_bulk_with_password_history() {
970        let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
971        let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
972
973        let mut cipher_view1 = test_cipher_view_without_org();
974        cipher_view1.id = Some(TEST_CIPHER_ID.parse().unwrap());
975        if let Some(ref mut login) = cipher_view1.login {
976            login.password = Some("original_password_1".to_string());
977        }
978
979        let mut cipher_view2 = test_cipher_view_without_org();
980        cipher_view2.id = Some("11111111-2222-3333-4444-555555555555".parse().unwrap());
981        if let Some(ref mut login) = cipher_view2.login {
982            login.password = Some("original_password_2".to_string());
983        }
984
985        // Set up wiremock server with mock that echoes back the request data
986        let mock = Mock::given(method("PUT"))
987            .and(path("/ciphers/share"))
988            .and(wiremock::matchers::body_string_contains("passwordHistory"))
989            .respond_with(move |req: &wiremock::Request| {
990                let body_bytes = req.body.as_slice();
991                let request_body: bitwarden_api_api::models::CipherBulkShareRequestModel =
992                    serde_json::from_slice(body_bytes).expect("Failed to parse request body");
993
994                // Echo back the cipher data
995                let ciphers: Vec<_> = request_body
996                    .ciphers
997                    .into_iter()
998                    .map(
999                        |cipher| bitwarden_api_api::models::CipherMiniResponseModel {
1000                            object: Some("cipherMini".to_string()),
1001                            id: Some(cipher.id),
1002                            organization_id: cipher.organization_id.and_then(|id| id.parse().ok()),
1003                            r#type: cipher.r#type,
1004                            name: Some(cipher.name),
1005                            notes: cipher.notes,
1006                            login: cipher.login,
1007                            reprompt: cipher.reprompt,
1008                            password_history: cipher.password_history,
1009                            revision_date: Some("2024-01-30T17:55:36.150Z".to_string()),
1010                            creation_date: Some("2024-01-30T17:55:36.150Z".to_string()),
1011                            organization_use_totp: Some(true),
1012                            fields: cipher.fields,
1013                            key: cipher.key,
1014                            ..Default::default()
1015                        },
1016                    )
1017                    .collect();
1018
1019                let response =
1020                    bitwarden_api_api::models::CipherMiniResponseModelListResponseModel {
1021                        object: Some("list".to_string()),
1022                        data: Some(ciphers),
1023                        continuation_token: None,
1024                    };
1025
1026                ResponseTemplate::new(200).set_body_json(&response)
1027            });
1028
1029        // Set up the client with mocked server and repository.
1030        let (mock_server, _config) = start_api_mock(vec![mock]).await;
1031        let client = make_test_client_with_wiremock(&mock_server).await;
1032        let repository = std::sync::Arc::new(MemoryRepository::<Cipher>::default());
1033        let cipher_client = client.vault().ciphers();
1034
1035        let encrypted_original1 = cipher_client.encrypt(cipher_view1.clone()).unwrap();
1036        repository
1037            .set(
1038                encrypted_original1.cipher.id.unwrap().to_string(),
1039                encrypted_original1.cipher.clone(),
1040            )
1041            .await
1042            .unwrap();
1043
1044        let encrypted_original2 = cipher_client.encrypt(cipher_view2.clone()).unwrap();
1045        repository
1046            .set(
1047                encrypted_original2.cipher.id.unwrap().to_string(),
1048                encrypted_original2.cipher.clone(),
1049            )
1050            .await
1051            .unwrap();
1052
1053        client
1054            .platform()
1055            .state()
1056            .register_client_managed(repository.clone());
1057
1058        // Change the passwords to make sure password_history is updated.
1059        if let Some(ref mut login) = cipher_view1.login {
1060            login.password = Some("new_password_1".to_string());
1061        }
1062        if let Some(ref mut login) = cipher_view2.login {
1063            login.password = Some("new_password_2".to_string());
1064        }
1065
1066        let result = cipher_client
1067            .share_ciphers_bulk(
1068                vec![cipher_view1, cipher_view2],
1069                org_id,
1070                vec![collection_id],
1071            )
1072            .await;
1073
1074        let shared_ciphers = result.unwrap();
1075        assert_eq!(shared_ciphers.len(), 2);
1076
1077        let decrypted_view1 = cipher_client.decrypt(shared_ciphers[0].clone()).unwrap();
1078        assert_eq!(
1079            decrypted_view1.password_history.unwrap()[0].password,
1080            "original_password_1"
1081        );
1082        assert_eq!(
1083            decrypted_view1.login.unwrap().password,
1084            Some("new_password_1".to_string())
1085        );
1086
1087        let decrypted_view2 = cipher_client.decrypt(shared_ciphers[1].clone()).unwrap();
1088        assert_eq!(
1089            decrypted_view2.password_history.unwrap()[0].password,
1090            "original_password_2"
1091        );
1092        assert_eq!(
1093            decrypted_view2.login.unwrap().password,
1094            Some("new_password_2".to_string())
1095        );
1096    }
1097}