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