bitwarden_vault/cipher/cipher_client/
delete.rs

1use bitwarden_api_api::models::CipherBulkDeleteRequestModel;
2use bitwarden_core::{ApiError, OrganizationId};
3use bitwarden_error::bitwarden_error;
4use bitwarden_state::repository::{Repository, RepositoryError};
5use thiserror::Error;
6
7use crate::{Cipher, CipherId, CiphersClient};
8
9#[allow(missing_docs)]
10#[bitwarden_error(flat)]
11#[derive(Debug, Error)]
12pub enum DeleteCipherError {
13    #[error(transparent)]
14    Api(#[from] ApiError),
15    #[error(transparent)]
16    Repository(#[from] RepositoryError),
17}
18
19impl<T> From<bitwarden_api_api::apis::Error<T>> for DeleteCipherError {
20    fn from(value: bitwarden_api_api::apis::Error<T>) -> Self {
21        Self::Api(value.into())
22    }
23}
24
25async fn delete_cipher<R: Repository<Cipher> + ?Sized>(
26    cipher_id: CipherId,
27    api_client: &bitwarden_api_api::apis::ApiClient,
28    repository: &R,
29) -> Result<(), DeleteCipherError> {
30    let api = api_client.ciphers_api();
31    api.delete(cipher_id.into()).await?;
32    repository.remove(cipher_id.to_string()).await?;
33    Ok(())
34}
35
36async fn delete_ciphers<R: Repository<Cipher> + ?Sized>(
37    cipher_ids: Vec<CipherId>,
38    organization_id: Option<OrganizationId>,
39    api_client: &bitwarden_api_api::apis::ApiClient,
40    repository: &R,
41) -> Result<(), DeleteCipherError> {
42    let api = api_client.ciphers_api();
43
44    api.delete_many(Some(CipherBulkDeleteRequestModel {
45        ids: cipher_ids.iter().map(|id| id.to_string()).collect(),
46        organization_id: organization_id.map(|id| id.to_string()),
47    }))
48    .await?;
49
50    for cipher_id in cipher_ids {
51        repository.remove(cipher_id.to_string()).await?;
52    }
53    Ok(())
54}
55
56async fn soft_delete<R: Repository<Cipher> + ?Sized>(
57    cipher_id: CipherId,
58    api_client: &bitwarden_api_api::apis::ApiClient,
59    repository: &R,
60) -> Result<(), DeleteCipherError> {
61    let api = api_client.ciphers_api();
62    api.put_delete(cipher_id.into()).await?;
63    process_soft_delete(repository, cipher_id).await?;
64    Ok(())
65}
66
67async fn soft_delete_many<R: Repository<Cipher> + ?Sized>(
68    cipher_ids: Vec<CipherId>,
69    organization_id: Option<OrganizationId>,
70    api_client: &bitwarden_api_api::apis::ApiClient,
71    repository: &R,
72) -> Result<(), DeleteCipherError> {
73    let api = api_client.ciphers_api();
74
75    api.put_delete_many(Some(CipherBulkDeleteRequestModel {
76        ids: cipher_ids.iter().map(|id| id.to_string()).collect(),
77        organization_id: organization_id.map(|id| id.to_string()),
78    }))
79    .await?;
80    for cipher_id in cipher_ids {
81        process_soft_delete(repository, cipher_id).await?;
82    }
83    Ok(())
84}
85
86async fn process_soft_delete<R: Repository<Cipher> + ?Sized>(
87    repository: &R,
88    cipher_id: CipherId,
89) -> Result<(), RepositoryError> {
90    let cipher: Option<Cipher> = repository.get(cipher_id.to_string()).await?;
91    if let Some(mut cipher) = cipher {
92        cipher.soft_delete();
93        repository.set(cipher_id.to_string(), cipher).await?;
94    }
95    Ok(())
96}
97
98impl CiphersClient {
99    /// Deletes the [Cipher] with the matching [CipherId] from the server.
100    pub async fn delete(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> {
101        let configs = self.client.internal.get_api_configurations().await;
102        delete_cipher(cipher_id, &configs.api_client, &*self.get_repository()?).await
103    }
104
105    /// Deletes all [Cipher] objects with a matching [CipherId] from the server.
106    pub async fn delete_many(
107        &self,
108        cipher_ids: Vec<CipherId>,
109        organization_id: Option<OrganizationId>,
110    ) -> Result<(), DeleteCipherError> {
111        let configs = self.client.internal.get_api_configurations().await;
112        delete_ciphers(
113            cipher_ids,
114            organization_id,
115            &configs.api_client,
116            &*self.get_repository()?,
117        )
118        .await
119    }
120
121    /// Soft-deletes the [Cipher] with the matching [CipherId] from the server.
122    pub async fn soft_delete(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> {
123        let configs = self.client.internal.get_api_configurations().await;
124        soft_delete(cipher_id, &configs.api_client, &*self.get_repository()?).await
125    }
126
127    /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server.
128    pub async fn soft_delete_many(
129        &self,
130        cipher_ids: Vec<CipherId>,
131        organization_id: Option<OrganizationId>,
132    ) -> Result<(), DeleteCipherError> {
133        soft_delete_many(
134            cipher_ids,
135            organization_id,
136            &self
137                .client
138                .internal
139                .get_api_configurations()
140                .await
141                .api_client,
142            &*self.get_repository()?,
143        )
144        .await
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use bitwarden_api_api::apis::ApiClient;
151    use bitwarden_state::repository::Repository;
152    use bitwarden_test::MemoryRepository;
153    use chrono::Utc;
154
155    use crate::{
156        Cipher, CipherId,
157        cipher_client::delete::{delete_cipher, delete_ciphers, soft_delete, soft_delete_many},
158    };
159
160    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
161    const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098";
162
163    fn generate_test_cipher() -> Cipher {
164        Cipher {
165            id: TEST_CIPHER_ID.parse().ok(),
166            name: "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap(),
167            r#type: crate::CipherType::Login,
168            notes: Default::default(),
169            organization_id: Default::default(),
170            folder_id: Default::default(),
171            favorite: Default::default(),
172            reprompt: Default::default(),
173            fields: Default::default(),
174            collection_ids: Default::default(),
175            key: Default::default(),
176            login: Default::default(),
177            identity: Default::default(),
178            card: Default::default(),
179            secure_note: Default::default(),
180            ssh_key: Default::default(),
181            organization_use_totp: Default::default(),
182            edit: Default::default(),
183            permissions: Default::default(),
184            view_password: Default::default(),
185            local_data: Default::default(),
186            attachments: Default::default(),
187            password_history: Default::default(),
188            creation_date: Default::default(),
189            deleted_date: Default::default(),
190            revision_date: Default::default(),
191            archived_date: Default::default(),
192            data: Default::default(),
193        }
194    }
195
196    #[tokio::test]
197    async fn test_delete() {
198        let api_client = ApiClient::new_mocked(move |mock| {
199            mock.ciphers_api
200                .expect_delete()
201                .returning(move |_model| Ok(()));
202        });
203
204        // let client = create_client_with_wiremock(mock_server).await;
205        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
206        let repository = MemoryRepository::<Cipher>::default();
207        repository
208            .set(cipher_id.to_string(), generate_test_cipher())
209            .await
210            .unwrap();
211
212        delete_cipher(cipher_id, &api_client, &repository)
213            .await
214            .unwrap();
215
216        let cipher = repository.get(cipher_id.to_string()).await.unwrap();
217        assert!(
218            cipher.is_none(),
219            "Cipher is deleted from the local repository"
220        );
221    }
222
223    #[tokio::test]
224    async fn test_delete_many() {
225        let api_client = ApiClient::new_mocked(move |mock| {
226            mock.ciphers_api
227                .expect_delete_many()
228                .returning(move |_model| Ok(()));
229        });
230        let repository = MemoryRepository::<Cipher>::default();
231
232        let cipher_1 = generate_test_cipher();
233        let mut cipher_2 = generate_test_cipher();
234        cipher_2.id = Some(TEST_CIPHER_ID_2.parse().unwrap());
235
236        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
237        let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap();
238
239        repository
240            .set(cipher_id.to_string(), cipher_1)
241            .await
242            .unwrap();
243        repository
244            .set(TEST_CIPHER_ID_2.to_string(), cipher_2)
245            .await
246            .unwrap();
247
248        delete_ciphers(vec![cipher_id, cipher_id_2], None, &api_client, &repository)
249            .await
250            .unwrap();
251
252        let cipher_1 = repository.get(cipher_id.to_string()).await.unwrap();
253        let cipher_2 = repository.get(cipher_id_2.to_string()).await.unwrap();
254        assert!(
255            cipher_1.is_none(),
256            "Cipher is deleted from the local repository"
257        );
258        assert!(
259            cipher_2.is_none(),
260            "Cipher is deleted from the local repository"
261        );
262    }
263
264    #[tokio::test]
265    async fn test_soft_delete() {
266        let api_client = ApiClient::new_mocked(move |mock| {
267            mock.ciphers_api
268                .expect_put_delete()
269                .returning(move |_model| Ok(()));
270        });
271        let repository = MemoryRepository::<Cipher>::default();
272
273        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
274        repository
275            .set(cipher_id.to_string(), generate_test_cipher())
276            .await
277            .unwrap();
278
279        let start_time = Utc::now();
280        soft_delete(cipher_id, &api_client, &repository)
281            .await
282            .unwrap();
283        let end_time = Utc::now();
284
285        let cipher: Cipher = repository
286            .get(cipher_id.to_string())
287            .await
288            .unwrap()
289            .unwrap();
290        assert!(
291            cipher.deleted_date.unwrap() >= start_time && cipher.deleted_date.unwrap() <= end_time,
292            "Cipher was flagged as deleted in the repository."
293        );
294    }
295
296    #[tokio::test]
297    async fn test_soft_delete_many() {
298        let api_client = ApiClient::new_mocked(move |mock| {
299            mock.ciphers_api
300                .expect_put_delete_many()
301                .returning(move |_model| Ok(()));
302        });
303        let repository = MemoryRepository::<Cipher>::default();
304
305        let cipher_1 = generate_test_cipher();
306        let mut cipher_2 = generate_test_cipher();
307        cipher_2.id = Some(TEST_CIPHER_ID_2.parse().unwrap());
308
309        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
310        let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap();
311        repository
312            .set(cipher_id.to_string(), cipher_1)
313            .await
314            .unwrap();
315        repository
316            .set(TEST_CIPHER_ID_2.to_string(), cipher_2)
317            .await
318            .unwrap();
319
320        let start_time = Utc::now();
321
322        soft_delete_many(vec![cipher_id, cipher_id_2], None, &api_client, &repository)
323            .await
324            .unwrap();
325        let end_time = Utc::now();
326
327        let cipher_1 = repository
328            .get(cipher_id.to_string())
329            .await
330            .unwrap()
331            .unwrap();
332        let cipher_2 = repository
333            .get(cipher_id_2.to_string())
334            .await
335            .unwrap()
336            .unwrap();
337
338        assert!(
339            cipher_1.deleted_date.unwrap() >= start_time
340                && cipher_1.deleted_date.unwrap() <= end_time,
341            "Cipher was flagged as deleted in the repository."
342        );
343        assert!(
344            cipher_2.deleted_date.unwrap() >= start_time
345                && cipher_2.deleted_date.unwrap() <= end_time,
346            "Cipher was flagged as deleted in the repository."
347        );
348    }
349}