bitwarden_vault/cipher/cipher_client/
bulk_update_collections.rs1use 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 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}