1use bitwarden_api_api::ResponseContent;
2use bitwarden_core::ApiError;
3use bitwarden_error::bitwarden_error;
4use bitwarden_state::{
5 Setting, register_setting_key,
6 repository::{Repository, RepositoryError},
7};
8use thiserror::Error;
9#[cfg(feature = "wasm")]
10use wasm_bindgen::prelude::*;
11
12use crate::{Send, SendId, send_client::SendClient};
13
14register_setting_key!(const PENDING_SEND_DELETIONS: Vec<SendId> = "pending_send_deletions");
16
17#[allow(missing_docs)]
18#[bitwarden_error(flat)]
19#[derive(Debug, Error)]
20pub enum DeleteSendError {
21 #[error(transparent)]
22 Api(#[from] ApiError),
23 #[error(transparent)]
24 Repository(#[from] RepositoryError),
25 #[error(transparent)]
26 State(#[from] bitwarden_state::SettingsError),
27}
28
29async fn delete_send<R: Repository<Send> + ?Sized>(
30 api_client: &bitwarden_api_api::apis::ApiClient,
31 repository: &R,
32 pending_deletions: &Setting<Vec<SendId>>,
33 send_id: SendId,
34) -> Result<(), DeleteSendError> {
35 if let Err(e) = api_client.sends_api().delete(&send_id.to_string()).await {
36 queue_pending_deletion(pending_deletions, send_id).await;
38 return Err(e.into());
39 }
40
41 repository.remove(send_id).await?;
42
43 Ok(())
44}
45
46async fn queue_pending_deletion(pending_deletions: &Setting<Vec<SendId>>, send_id: SendId) {
49 let mut queue = match pending_deletions.get().await {
50 Ok(queue) => queue.unwrap_or_default(),
51 Err(e) => {
52 tracing::warn!("Failed to read pending send deletions: {e}");
53 return;
54 }
55 };
56
57 if queue.contains(&send_id) {
58 return;
59 }
60
61 queue.push(send_id);
62 if let Err(e) = pending_deletions.update(queue).await {
63 tracing::warn!("Failed to queue send for pending deletion: {e}");
64 }
65}
66
67async fn retry_pending_deletions<R: Repository<Send> + ?Sized>(
68 api_client: &bitwarden_api_api::apis::ApiClient,
69 repository: &R,
70 pending_deletions: &Setting<Vec<SendId>>,
71) -> Result<(), DeleteSendError> {
72 let queue = pending_deletions.get().await?.unwrap_or_default();
73 if queue.is_empty() {
74 return Ok(());
75 }
76
77 let mut resolved = Vec::new();
78 for send_id in queue {
79 match api_client.sends_api().delete(&send_id.to_string()).await {
80 Ok(())
82 | Err(ApiError::Response(ResponseContent {
83 status: reqwest::StatusCode::NOT_FOUND,
84 ..
85 })) => {
86 let _ = repository.remove(send_id).await;
88 resolved.push(send_id);
89 }
90 Err(_) => {}
91 }
92 }
93
94 if resolved.is_empty() {
95 return Ok(());
96 }
97
98 let mut current = pending_deletions.get().await?.unwrap_or_default();
100 current.retain(|id| !resolved.contains(id));
101 pending_deletions.update(current).await?;
102
103 Ok(())
104}
105
106#[cfg_attr(feature = "wasm", wasm_bindgen)]
107impl SendClient {
108 pub async fn delete(&self, send_id: SendId) -> Result<(), DeleteSendError> {
110 let config = self.client.internal.get_api_configurations();
111 let repository = self.get_repository()?;
112 let pending_deletions = self
113 .client
114 .platform()
115 .state()
116 .setting(PENDING_SEND_DELETIONS)?;
117
118 delete_send(
119 &config.api_client,
120 repository.as_ref(),
121 &pending_deletions,
122 send_id,
123 )
124 .await
125 }
126
127 pub async fn retry_pending_deletions(&self) -> Result<(), DeleteSendError> {
130 let config = self.client.internal.get_api_configurations();
131 let repository = self.get_repository()?;
132 let pending_deletions = self
133 .client
134 .platform()
135 .state()
136 .setting(PENDING_SEND_DELETIONS)?;
137
138 retry_pending_deletions(&config.api_client, repository.as_ref(), &pending_deletions).await
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use std::sync::Arc;
145
146 use bitwarden_api_api::apis::ApiClient;
147 use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
148 use bitwarden_crypto::{KeyStore, SymmetricKeyAlgorithm};
149 use bitwarden_test::{MemoryRepository, MemorySetting};
150 use uuid::uuid;
151
152 use super::*;
153 use crate::{AuthType, Send, SendId, SendTextView, SendType, SendView};
154
155 fn make_pending_setting() -> Setting<Vec<SendId>> {
156 MemorySetting::create()
157 }
158
159 async fn make_store_with_send(
160 send_id: uuid::Uuid,
161 ) -> (KeyStore<KeySlotIds>, MemoryRepository<Send>) {
162 let store: KeyStore<KeySlotIds> = KeyStore::default();
163 {
164 let mut ctx = store.context_mut();
165 let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
166 ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
167 .unwrap();
168 }
169
170 let repository = MemoryRepository::<Send>::default();
171 let send_view = SendView {
172 id: None,
173 access_id: None,
174 name: "Test Send".to_string(),
175 notes: None,
176 key: None,
177 new_password: None,
178 has_password: false,
179 r#type: SendType::Text,
180 file: None,
181 text: Some(SendTextView {
182 text: Some("Secret text".to_string()),
183 hidden: false,
184 }),
185 data: None,
186 max_access_count: None,
187 access_count: 0,
188 disabled: false,
189 hide_email: false,
190 revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
191 deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
192 expiration_date: None,
193 emails: Vec::new(),
194 auth_type: AuthType::None,
195 };
196 let mut send = store.encrypt(send_view).unwrap();
197 send.id = Some(SendId::new(send_id));
198 repository.set(SendId::new(send_id), send).await.unwrap();
199
200 (store, repository)
201 }
202
203 #[tokio::test]
204 async fn test_delete_send() {
205 let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
206 let (_store, repository) = make_store_with_send(send_id).await;
207 let pending = make_pending_setting();
208
209 let api_client = ApiClient::new_mocked(move |mock| {
210 mock.sends_api
211 .expect_delete()
212 .returning(move |_id| Ok(()))
213 .once();
214 });
215
216 let result = delete_send(&api_client, &repository, &pending, SendId::new(send_id)).await;
217
218 assert!(result.is_ok());
219 assert!(
220 repository
221 .get(SendId::new(send_id))
222 .await
223 .unwrap()
224 .is_none()
225 );
226 }
227
228 #[tokio::test]
229 async fn test_delete_send_http_error() {
230 let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
231 let (_store, repository) = make_store_with_send(send_id).await;
232 let pending = make_pending_setting();
233
234 let api_client = ApiClient::new_mocked(move |mock| {
235 mock.sends_api
236 .expect_delete()
237 .returning(move |_id| {
238 Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
239 "Simulated error",
240 )))
241 })
242 .once();
243 });
244
245 let result = delete_send(&api_client, &repository, &pending, SendId::new(send_id)).await;
246
247 assert!(result.is_err());
248 assert!(matches!(result.unwrap_err(), DeleteSendError::Api(_)));
249 assert!(
251 repository
252 .get(SendId::new(send_id))
253 .await
254 .unwrap()
255 .is_some()
256 );
257 }
258
259 #[tokio::test]
260 async fn test_delete_send_failure_queues_pending_deletion() {
261 let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
262 let (_store, repository) = make_store_with_send(send_id).await;
263 let pending = make_pending_setting();
264
265 let api_client = ApiClient::new_mocked(move |mock| {
266 mock.sends_api.expect_delete().returning(move |_id| {
267 Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
268 "Simulated error",
269 )))
270 });
271 });
272
273 let result = delete_send(&api_client, &repository, &pending, SendId::new(send_id)).await;
274 assert!(result.is_err());
275
276 let queue = pending.get().await.unwrap().unwrap_or_default();
277 assert_eq!(queue, vec![SendId::new(send_id)]);
278 }
279
280 #[tokio::test]
281 async fn test_delete_send_failure_does_not_duplicate_queue_entry() {
282 let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
283 let (_store, repository) = make_store_with_send(send_id).await;
284 let pending = make_pending_setting();
285
286 let api_client = ApiClient::new_mocked(move |mock| {
287 mock.sends_api.expect_delete().returning(move |_id| {
288 Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
289 "Simulated error",
290 )))
291 });
292 });
293
294 assert!(
295 delete_send(&api_client, &repository, &pending, SendId::new(send_id))
296 .await
297 .is_err()
298 );
299 assert!(
300 delete_send(&api_client, &repository, &pending, SendId::new(send_id))
301 .await
302 .is_err()
303 );
304
305 let queue = pending.get().await.unwrap().unwrap_or_default();
306 assert_eq!(queue, vec![SendId::new(send_id)]);
307 }
308
309 #[tokio::test]
310 async fn test_retry_pending_deletions_success_removes_from_queue_and_repository() {
311 let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
312 let (_store, repository) = make_store_with_send(send_id).await;
313 let pending = make_pending_setting();
314 pending.update(vec![SendId::new(send_id)]).await.unwrap();
315
316 let api_client = ApiClient::new_mocked(move |mock| {
317 mock.sends_api
318 .expect_delete()
319 .returning(move |_id| Ok(()))
320 .once();
321 });
322
323 retry_pending_deletions(&api_client, &repository, &pending)
324 .await
325 .unwrap();
326
327 let queue = pending.get().await.unwrap().unwrap_or_default();
328 assert!(queue.is_empty());
329 assert!(
330 repository
331 .get(SendId::new(send_id))
332 .await
333 .unwrap()
334 .is_none()
335 );
336 }
337
338 #[tokio::test]
339 async fn test_retry_pending_deletions_not_found_is_resolved() {
340 let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
341 let (_store, repository) = make_store_with_send(send_id).await;
342 let pending = make_pending_setting();
343 pending.update(vec![SendId::new(send_id)]).await.unwrap();
344
345 let api_client = ApiClient::new_mocked(move |mock| {
346 mock.sends_api
347 .expect_delete()
348 .returning(move |_id| {
349 Err(bitwarden_api_api::ApiError::Response(ResponseContent {
350 status: reqwest::StatusCode::NOT_FOUND,
351 message: "not found".to_string(),
352 }))
353 })
354 .once();
355 });
356
357 retry_pending_deletions(&api_client, &repository, &pending)
358 .await
359 .unwrap();
360
361 let queue = pending.get().await.unwrap().unwrap_or_default();
362 assert!(queue.is_empty());
363 assert!(
364 repository
365 .get(SendId::new(send_id))
366 .await
367 .unwrap()
368 .is_none()
369 );
370 }
371
372 #[tokio::test]
373 async fn test_retry_pending_deletions_other_error_stays_queued() {
374 let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
375 let (_store, repository) = make_store_with_send(send_id).await;
376 let pending = make_pending_setting();
377 pending.update(vec![SendId::new(send_id)]).await.unwrap();
378
379 let api_client = ApiClient::new_mocked(move |mock| {
380 mock.sends_api
381 .expect_delete()
382 .returning(move |_id| {
383 Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
384 "Simulated error",
385 )))
386 })
387 .once();
388 });
389
390 retry_pending_deletions(&api_client, &repository, &pending)
391 .await
392 .unwrap();
393
394 let queue = pending.get().await.unwrap().unwrap_or_default();
395 assert_eq!(queue, vec![SendId::new(send_id)]);
396 assert!(
398 repository
399 .get(SendId::new(send_id))
400 .await
401 .unwrap()
402 .is_some()
403 );
404 }
405
406 #[tokio::test]
407 async fn test_retry_pending_deletions_mixed_batch() {
408 let resolved_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
409 let failing_id = uuid!("3b8e0f2a-1c4d-4e6f-9a7b-2d5c8e1f0a3b");
410 let (store, repository) = make_store_with_send(resolved_id).await;
411 let mut send = store
413 .encrypt(SendView {
414 id: None,
415 access_id: None,
416 name: "Test Send".to_string(),
417 notes: None,
418 key: None,
419 new_password: None,
420 has_password: false,
421 r#type: SendType::Text,
422 file: None,
423 text: Some(SendTextView {
424 text: Some("Secret text".to_string()),
425 hidden: false,
426 }),
427 data: None,
428 max_access_count: None,
429 access_count: 0,
430 disabled: false,
431 hide_email: false,
432 revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
433 deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
434 expiration_date: None,
435 emails: Vec::new(),
436 auth_type: AuthType::None,
437 })
438 .unwrap();
439 send.id = Some(SendId::new(failing_id));
440 repository.set(SendId::new(failing_id), send).await.unwrap();
441
442 let pending = make_pending_setting();
443 pending
444 .update(vec![SendId::new(resolved_id), SendId::new(failing_id)])
445 .await
446 .unwrap();
447
448 let resolved_id_str = resolved_id.to_string();
449 let api_client = ApiClient::new_mocked(move |mock| {
450 mock.sends_api.expect_delete().returning(move |id| {
451 if id == resolved_id_str {
452 Ok(())
453 } else {
454 Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
455 "Simulated error",
456 )))
457 }
458 });
459 });
460
461 retry_pending_deletions(&api_client, &repository, &pending)
462 .await
463 .unwrap();
464
465 let queue = pending.get().await.unwrap().unwrap_or_default();
466 assert_eq!(queue, vec![SendId::new(failing_id)]);
467 assert!(
468 repository
469 .get(SendId::new(resolved_id))
470 .await
471 .unwrap()
472 .is_none()
473 );
474 assert!(
475 repository
476 .get(SendId::new(failing_id))
477 .await
478 .unwrap()
479 .is_some()
480 );
481 }
482
483 #[tokio::test]
484 async fn test_retry_pending_deletions_empty_queue_is_noop() {
485 let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
486 let (_store, repository) = make_store_with_send(send_id).await;
487 let pending = make_pending_setting();
488
489 let api_client = ApiClient::new_mocked(|_mock| {});
491
492 retry_pending_deletions(&api_client, &repository, &pending)
493 .await
494 .unwrap();
495 }
496
497 #[tokio::test]
498 async fn test_delete_send_failure_then_retry_success_resolves_orphan() {
499 let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
500 let (_store, repository) = make_store_with_send(send_id).await;
501 let pending = make_pending_setting();
502
503 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
504 let api_client = ApiClient::new_mocked(move |mock| {
505 let attempts = attempts.clone();
506 mock.sends_api.expect_delete().returning(move |_id| {
507 if attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
508 Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
509 "offline",
510 )))
511 } else {
512 Ok(())
513 }
514 });
515 });
516
517 let result = delete_send(&api_client, &repository, &pending, SendId::new(send_id)).await;
519 assert!(result.is_err());
520 assert!(
521 repository
522 .get(SendId::new(send_id))
523 .await
524 .unwrap()
525 .is_some(),
526 "orphaned Send stays visible until a retry resolves it"
527 );
528
529 retry_pending_deletions(&api_client, &repository, &pending)
531 .await
532 .unwrap();
533
534 assert!(
535 repository
536 .get(SendId::new(send_id))
537 .await
538 .unwrap()
539 .is_none()
540 );
541 assert!(pending.get().await.unwrap().unwrap_or_default().is_empty());
542 }
543}