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