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