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