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