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