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
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 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}