Skip to main content

bitwarden_vault/cipher/cipher_client/
share_cipher.rs

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