Skip to main content

bitwarden_vault/cipher/cipher_client/
bulk_update_collections.rs

1use std::collections::HashSet;
2
3use bitwarden_api_api::models::CipherBulkUpdateCollectionsRequestModel;
4use bitwarden_collections::collection::CollectionId;
5use bitwarden_core::{ApiError, OrganizationId};
6use bitwarden_error::bitwarden_error;
7use bitwarden_state::repository::{RepositoryError, RepositoryOption};
8use thiserror::Error;
9
10use crate::{CipherId, CiphersClient};
11
12#[allow(missing_docs)]
13#[bitwarden_error(flat)]
14#[derive(Debug, Error)]
15pub enum BulkUpdateCollectionsCipherError {
16    #[error(transparent)]
17    Api(#[from] ApiError),
18    #[error(transparent)]
19    Repository(#[from] RepositoryError),
20}
21
22#[bitwarden_ffi::wasm_export]
23impl CiphersClient {
24    /// Updates collection membership for multiple [`Cipher`](crate::Cipher) objects.
25    ///
26    /// When `remove_collections` is `true`, the given collection IDs are removed from each cipher.
27    /// When `false`, they are added without introducing duplicates.
28    pub async fn bulk_update_collections(
29        &self,
30        organization_id: OrganizationId,
31        cipher_ids: Vec<CipherId>,
32        collection_ids: Vec<CollectionId>,
33        remove_collections: bool,
34    ) -> Result<(), BulkUpdateCollectionsCipherError> {
35        self.api_configurations
36            .api_client
37            .ciphers_api()
38            .post_bulk_collections(Some(CipherBulkUpdateCollectionsRequestModel {
39                organization_id: Some(organization_id.into()),
40                cipher_ids: Some(cipher_ids.iter().map(|id| (*id).into()).collect()),
41                collection_ids: Some(collection_ids.iter().map(|id| (*id).into()).collect()),
42                remove_collections: Some(remove_collections),
43            }))
44            .await?;
45
46        let repository = self.repository.require()?;
47        let mut updated_ciphers = Vec::new();
48        let collection_ids = collection_ids.iter().copied().collect::<HashSet<_>>();
49        for cipher_id in cipher_ids {
50            if let Some(mut cipher) = repository.get(cipher_id).await? {
51                if remove_collections {
52                    cipher
53                        .collection_ids
54                        .retain(|id| !collection_ids.contains(id));
55                } else {
56                    let existing = cipher
57                        .collection_ids
58                        .iter()
59                        .copied()
60                        .collect::<HashSet<_>>();
61                    cipher.collection_ids = cipher
62                        .collection_ids
63                        .into_iter()
64                        .chain(
65                            collection_ids
66                                .clone()
67                                .into_iter()
68                                .filter(|id| !existing.contains(id)),
69                        )
70                        .collect();
71                }
72                updated_ciphers.push((cipher_id, cipher));
73            }
74        }
75        repository.set_bulk(updated_ciphers).await?;
76
77        Ok(())
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use std::sync::Arc;
84
85    use bitwarden_api_api::apis::ApiClient;
86    use bitwarden_collections::collection::CollectionId;
87    use bitwarden_core::{
88        OrganizationId, client::ApiConfigurations, key_management::create_test_crypto_with_user_key,
89    };
90    use bitwarden_crypto::{SymmetricCryptoKey, SymmetricKeyAlgorithm};
91    use bitwarden_state::repository::Repository;
92    use bitwarden_test::MemoryRepository;
93
94    use crate::{Cipher, CipherId, CiphersClient};
95
96    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
97    const TEST_ORG_ID: &str = "7faa9684-c793-4a2d-8a12-b33900187099";
98    const TEST_COLLECTION_ID_1: &str = "8faa9684-c793-4a2d-8a12-b33900187100";
99
100    fn generate_test_cipher() -> Cipher {
101        Cipher {
102            partial_data: None,
103            id: TEST_CIPHER_ID.parse().ok(),
104            name: Some("2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap()),
105            r#type: crate::CipherType::Login,
106            notes: Default::default(),
107            organization_id: Default::default(),
108            folder_id: Default::default(),
109            favorite: Default::default(),
110            reprompt: Default::default(),
111            fields: Default::default(),
112            collection_ids: Default::default(),
113            key: Default::default(),
114            login: Default::default(),
115            identity: Default::default(),
116            card: Default::default(),
117            secure_note: Default::default(),
118            ssh_key: Default::default(),
119            bank_account: Default::default(),
120            drivers_license: Default::default(),
121            passport: Default::default(),
122            organization_use_totp: Default::default(),
123            edit: Default::default(),
124            permissions: Default::default(),
125            view_password: Default::default(),
126            local_data: Default::default(),
127            attachments: Default::default(),
128            password_history: Default::default(),
129            creation_date: Default::default(),
130            deleted_date: Default::default(),
131            revision_date: Default::default(),
132            archived_date: Default::default(),
133            data: Default::default(),
134        }
135    }
136
137    fn create_test_client(api_client: ApiClient) -> (CiphersClient, Arc<MemoryRepository<Cipher>>) {
138        let repository = Arc::new(MemoryRepository::<Cipher>::default());
139        #[allow(deprecated)]
140        let client = CiphersClient {
141            key_store: create_test_crypto_with_user_key(SymmetricCryptoKey::make(
142                SymmetricKeyAlgorithm::Aes256CbcHmac,
143            )),
144            api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
145            repository: Some(repository.clone() as Arc<dyn Repository<Cipher>>),
146            client: bitwarden_core::Client::new_test(None),
147        };
148        (client, repository)
149    }
150
151    fn make_api_client() -> ApiClient {
152        ApiClient::new_mocked(|mock| {
153            mock.ciphers_api
154                .expect_post_bulk_collections()
155                .returning(|_| Ok(()));
156        })
157    }
158
159    #[tokio::test]
160    async fn test_bulk_update_adds_collections() {
161        let (client, repository) = create_test_client(make_api_client());
162
163        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
164        let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
165        let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
166
167        repository
168            .set(cipher_id, generate_test_cipher())
169            .await
170            .unwrap();
171
172        client
173            .bulk_update_collections(org_id, vec![cipher_id], vec![collection_id], false)
174            .await
175            .unwrap();
176
177        let c: Cipher = repository.get(cipher_id).await.unwrap().unwrap();
178        assert!(c.collection_ids.contains(&collection_id));
179    }
180
181    #[tokio::test]
182    async fn test_bulk_update_removes_collections() {
183        let (client, repository) = create_test_client(make_api_client());
184
185        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
186        let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
187        let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
188
189        let mut cipher = generate_test_cipher();
190        cipher.collection_ids = vec![collection_id];
191        repository.set(cipher_id, cipher).await.unwrap();
192
193        client
194            .bulk_update_collections(org_id, vec![cipher_id], vec![collection_id], true)
195            .await
196            .unwrap();
197
198        let c: Cipher = repository.get(cipher_id).await.unwrap().unwrap();
199        assert!(!c.collection_ids.contains(&collection_id));
200    }
201
202    #[tokio::test]
203    async fn test_bulk_update_no_duplicates_when_adding() {
204        let (client, repository) = create_test_client(make_api_client());
205
206        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
207        let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
208        let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
209
210        let mut cipher = generate_test_cipher();
211        cipher.collection_ids = vec![collection_id];
212        repository.set(cipher_id, cipher).await.unwrap();
213
214        client
215            .bulk_update_collections(org_id, vec![cipher_id], vec![collection_id], false)
216            .await
217            .unwrap();
218
219        let c: Cipher = repository.get(cipher_id).await.unwrap().unwrap();
220        assert_eq!(
221            c.collection_ids.len(),
222            1,
223            "no duplicates introduced when collection already present"
224        );
225    }
226
227    #[tokio::test]
228    async fn test_bulk_update_skips_missing_ciphers() {
229        let (client, _repository) = create_test_client(make_api_client());
230
231        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
232        let org_id: OrganizationId = TEST_ORG_ID.parse().unwrap();
233        let collection_id: CollectionId = TEST_COLLECTION_ID_1.parse().unwrap();
234
235        let result = client
236            .bulk_update_collections(org_id, vec![cipher_id], vec![collection_id], false)
237            .await;
238        assert!(result.is_ok());
239    }
240}