1use std::sync::Arc;
21use std::time::Duration;
22
23use allowthem_core::webhook_sig::sign_payload;
24use chrono::{DateTime, Utc};
25use sqlx::SqlitePool;
26use tokio::sync::Semaphore;
27use tokio::sync::watch;
28use tokio::time::sleep;
29
30#[derive(Debug, Clone)]
34pub struct WebhookWorkerConfig {
35 pub poll_interval: Duration,
38 pub batch_size: i64,
40 pub concurrency: usize,
42 pub request_timeout: Duration,
44}
45
46impl Default for WebhookWorkerConfig {
47 fn default() -> Self {
48 Self {
49 poll_interval: Duration::from_secs(5),
50 batch_size: 64,
51 concurrency: 10,
52 request_timeout: Duration::from_secs(10),
53 }
54 }
55}
56
57pub struct WebhookWorker {
59 pool: SqlitePool,
60 http: reqwest::Client,
61 config: WebhookWorkerConfig,
62 sem: Arc<Semaphore>,
63}
64
65impl WebhookWorker {
66 pub fn new(pool: SqlitePool, config: WebhookWorkerConfig) -> Self {
71 let http = reqwest::Client::builder()
72 .timeout(config.request_timeout)
73 .user_agent("allowthem-webhook/1")
74 .build()
75 .expect("reqwest::Client::build is infallible with default features");
76 let sem = Arc::new(Semaphore::new(config.concurrency));
77 Self {
78 pool,
79 http,
80 config,
81 sem,
82 }
83 }
84
85 pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
89 loop {
90 if *shutdown.borrow() {
92 return;
93 }
94
95 match self.claim_due_batch().await {
96 Ok(claims) if !claims.is_empty() => {
97 for claim in claims {
98 let permit = match self.sem.clone().acquire_owned().await {
99 Ok(p) => p,
100 Err(_) => return, };
102 let pool = self.pool.clone();
103 let http = self.http.clone();
104 tokio::spawn(async move {
105 dispatch_and_record(&pool, &http, claim).await;
107 drop(permit);
108 });
109 }
110 }
112 Ok(_) => {
113 tokio::select! {
115 _ = sleep(self.config.poll_interval) => {}
116 _ = shutdown.changed() => {}
117 }
118 }
119 Err(e) => {
120 tracing::warn!(error = %e, "webhook worker: claim_due_batch failed");
121 tokio::select! {
124 _ = sleep(self.config.poll_interval) => {}
125 _ = shutdown.changed() => {}
126 }
127 }
128 }
129 }
130 }
131
132 async fn claim_due_batch(&self) -> Result<Vec<ClaimedDelivery>, sqlx::Error> {
136 #[allow(clippy::type_complexity)]
142 let claimed: Vec<(Vec<u8>, Vec<u8>, String, String, i64)> = sqlx::query_as(
143 "UPDATE webhook_deliveries \
144 SET status = 'in_flight', \
145 last_attempt_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
146 WHERE id IN ( \
147 SELECT id FROM webhook_deliveries \
148 WHERE status = 'pending' \
149 AND (next_retry_at IS NULL \
150 OR next_retry_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) \
151 ORDER BY created_at ASC \
152 LIMIT ?1 \
153 ) \
154 RETURNING id, webhook_id, event_id, event_type, attempts",
155 )
156 .bind(self.config.batch_size)
157 .fetch_all(&self.pool)
158 .await?;
159
160 if claimed.is_empty() {
161 return Ok(Vec::new());
162 }
163
164 let mut out = Vec::with_capacity(claimed.len());
166 for (id, _webhook_id, _event_id, event_type, attempts) in claimed {
167 let row: Option<(String, String, String)> = sqlx::query_as(
168 "SELECT w.url, w.secret_key, d.payload \
169 FROM tenant_webhooks w \
170 JOIN webhook_deliveries d ON d.webhook_id = w.id \
171 WHERE d.id = ?1",
172 )
173 .bind(&id)
174 .fetch_optional(&self.pool)
175 .await?;
176
177 let (url, secret, payload) = match row {
178 Some(t) => t,
179 None => {
180 sqlx::query(
183 "UPDATE webhook_deliveries \
184 SET status = 'failed', \
185 last_attempt_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
186 WHERE id = ?1",
187 )
188 .bind(&id)
189 .execute(&self.pool)
190 .await?;
191 continue;
192 }
193 };
194
195 out.push(ClaimedDelivery {
196 id,
197 event_type,
198 attempts,
199 url,
200 secret,
201 payload,
202 });
203 }
204
205 Ok(out)
206 }
207}
208
209#[derive(Debug, Clone)]
211struct ClaimedDelivery {
212 id: Vec<u8>,
213 event_type: String,
214 attempts: i64,
215 url: String,
216 secret: String,
217 payload: String,
218}
219
220fn next_retry_after(attempts_after_failure: i64) -> Option<Duration> {
227 match attempts_after_failure {
228 1 => Some(Duration::from_secs(60)),
229 2 => Some(Duration::from_secs(5 * 60)),
230 3 => Some(Duration::from_secs(30 * 60)),
231 4 => Some(Duration::from_secs(2 * 60 * 60)),
232 5 => Some(Duration::from_secs(12 * 60 * 60)),
233 _ => None,
234 }
235}
236
237async fn dispatch_and_record(pool: &SqlitePool, http: &reqwest::Client, claim: ClaimedDelivery) {
238 let now_ts = Utc::now().timestamp();
239 let signature = sign_payload(claim.secret.as_bytes(), now_ts, claim.payload.as_bytes());
240
241 let result = http
242 .post(&claim.url)
243 .header("Content-Type", "application/json")
244 .header("X-Allowthem-Event", &claim.event_type)
245 .header("X-Allowthem-Signature", &signature)
246 .body(claim.payload.clone())
247 .send()
248 .await;
249
250 match result {
251 Ok(resp) => {
252 let code = resp.status().as_u16() as i64;
253 if resp.status().is_success() {
254 if let Err(e) = mark_delivered(pool, &claim.id, code).await {
255 tracing::warn!(
256 error = %e,
257 "webhook worker: failed to mark delivered"
258 );
259 }
260 } else if let Err(e) =
261 schedule_retry_or_fail(pool, &claim.id, claim.attempts + 1, Some(code)).await
262 {
263 tracing::warn!(error = %e, "webhook worker: failed to record non-2xx");
264 }
265 }
266 Err(e) => {
267 tracing::warn!(
268 url = %claim.url,
269 error = %e,
270 "webhook worker: HTTP transport error"
271 );
272 if let Err(e) = schedule_retry_or_fail(pool, &claim.id, claim.attempts + 1, None).await
273 {
274 tracing::warn!(error = %e, "webhook worker: failed to record transport error");
275 }
276 }
277 }
278}
279
280async fn mark_delivered(
281 pool: &SqlitePool,
282 id: &[u8],
283 response_code: i64,
284) -> Result<(), sqlx::Error> {
285 sqlx::query(
286 "UPDATE webhook_deliveries \
287 SET status = 'delivered', \
288 response_code = ?2, \
289 last_attempt_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
290 WHERE id = ?1",
291 )
292 .bind(id)
293 .bind(response_code)
294 .execute(pool)
295 .await
296 .map(|_| ())
297}
298
299async fn schedule_retry_or_fail(
300 pool: &SqlitePool,
301 id: &[u8],
302 new_attempts: i64,
303 response_code: Option<i64>,
304) -> Result<(), sqlx::Error> {
305 if let Some(delay) = next_retry_after(new_attempts) {
306 let next_retry: DateTime<Utc> = Utc::now()
307 + chrono::Duration::from_std(delay).expect("retry schedule fits in chrono::Duration");
308 sqlx::query(
309 "UPDATE webhook_deliveries \
310 SET status = 'pending', \
311 attempts = ?2, \
312 response_code = ?3, \
313 last_attempt_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), \
314 next_retry_at = ?4 \
315 WHERE id = ?1",
316 )
317 .bind(id)
318 .bind(new_attempts)
319 .bind(response_code)
320 .bind(next_retry)
321 .execute(pool)
322 .await
323 .map(|_| ())
324 } else {
325 sqlx::query(
326 "UPDATE webhook_deliveries \
327 SET status = 'failed', \
328 attempts = ?2, \
329 response_code = ?3, \
330 last_attempt_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \
331 WHERE id = ?1",
332 )
333 .bind(id)
334 .bind(new_attempts)
335 .bind(response_code)
336 .execute(pool)
337 .await
338 .map(|_| ())
339 }
340}
341
342#[cfg(test)]
347mod tests {
348 use super::*;
349 use crate::control_db::ControlDb;
350 use crate::control_db::tests::test_pool;
351 use crate::tenants::TenantId;
352 use std::sync::Mutex;
353 use std::sync::atomic::{AtomicUsize, Ordering};
354 use uuid::Uuid;
355 use wiremock::matchers::{method, path};
356 use wiremock::{Mock, MockServer, ResponseTemplate};
357
358 #[test]
361 fn next_retry_after_full_schedule() {
362 assert_eq!(next_retry_after(1), Some(Duration::from_secs(60)));
363 assert_eq!(next_retry_after(2), Some(Duration::from_secs(5 * 60)));
364 assert_eq!(next_retry_after(3), Some(Duration::from_secs(30 * 60)));
365 assert_eq!(next_retry_after(4), Some(Duration::from_secs(2 * 60 * 60)));
366 assert_eq!(next_retry_after(5), Some(Duration::from_secs(12 * 60 * 60)));
367 assert_eq!(next_retry_after(6), None);
368 assert_eq!(next_retry_after(7), None);
369 }
370
371 async fn seed_tenant(db: &ControlDb, slug: &str) -> TenantId {
375 let plan_id: Vec<u8> = sqlx::query_scalar("SELECT id FROM tenant_plans LIMIT 1")
376 .fetch_one(db.pool())
377 .await
378 .unwrap();
379 let tid = Uuid::now_v7();
380 sqlx::query(
381 "INSERT INTO tenants (id, name, slug, owner_email, plan_id, status, db_path) \
382 VALUES (?, ?, ?, ?, ?, 'active', ?)",
383 )
384 .bind(tid.as_bytes().as_ref())
385 .bind(slug)
386 .bind(slug)
387 .bind(format!("{slug}@example.com"))
388 .bind(&plan_id)
389 .bind(format!("{slug}.db"))
390 .execute(db.pool())
391 .await
392 .unwrap();
393 TenantId::from(tid)
394 }
395
396 async fn seed_webhook(db: &ControlDb, tenant_id: TenantId, url: &str, secret: &str) -> Vec<u8> {
397 let id = Uuid::now_v7();
398 sqlx::query(
399 "INSERT INTO tenant_webhooks (id, tenant_id, url, secret_key, event_types, enabled) \
400 VALUES (?, ?, ?, ?, ?, 1)",
401 )
402 .bind(id.as_bytes().as_ref())
403 .bind(tenant_id.as_bytes())
404 .bind(url)
405 .bind(secret)
406 .bind(r#"["user.created"]"#)
407 .execute(db.pool())
408 .await
409 .unwrap();
410 id.as_bytes().to_vec()
411 }
412
413 async fn insert_pending(
414 db: &ControlDb,
415 tenant_id: TenantId,
416 webhook_id: &[u8],
417 attempts: i64,
418 ) -> Vec<u8> {
419 let id = Uuid::now_v7();
420 let event_id = Uuid::now_v7().to_string();
421 sqlx::query(
422 "INSERT INTO webhook_deliveries \
423 (id, tenant_id, webhook_id, event_id, event_type, payload, status, attempts) \
424 VALUES (?, ?, ?, ?, 'user.created', '{\"event_id\":\"x\"}', 'pending', ?)",
425 )
426 .bind(id.as_bytes().as_ref())
427 .bind(tenant_id.as_bytes())
428 .bind(webhook_id)
429 .bind(&event_id)
430 .bind(attempts)
431 .execute(db.pool())
432 .await
433 .unwrap();
434 id.as_bytes().to_vec()
435 }
436
437 async fn delivery_row(
438 db: &ControlDb,
439 id: &[u8],
440 ) -> (String, i64, Option<i64>, Option<DateTime<Utc>>) {
441 sqlx::query_as::<_, (String, i64, Option<i64>, Option<DateTime<Utc>>)>(
442 "SELECT status, attempts, response_code, next_retry_at \
443 FROM webhook_deliveries WHERE id = ?",
444 )
445 .bind(id)
446 .fetch_one(db.pool())
447 .await
448 .unwrap()
449 }
450
451 fn small_config() -> WebhookWorkerConfig {
452 WebhookWorkerConfig {
453 poll_interval: Duration::from_millis(50),
454 batch_size: 16,
455 concurrency: 4,
456 request_timeout: Duration::from_secs(2),
457 }
458 }
459
460 #[tokio::test]
463 async fn dispatch_marks_delivered_on_2xx_and_sends_signature_header() {
464 let server = MockServer::start().await;
465 let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
466 {
467 let captured = captured.clone();
468 Mock::given(method("POST"))
469 .and(path("/hook"))
470 .respond_with(move |req: &wiremock::Request| {
471 let sig = req
472 .headers
473 .get("x-allowthem-signature")
474 .map(|v| v.to_str().unwrap().to_owned());
475 *captured.lock().unwrap() = sig;
476 ResponseTemplate::new(200)
477 })
478 .mount(&server)
479 .await;
480 }
481
482 let db = ControlDb::new(test_pool().await).await.unwrap();
483 let tenant = seed_tenant(&db, "acme").await;
484 let url = format!("{}/hook", server.uri());
485 let webhook_id = seed_webhook(&db, tenant, &url, "test-secret").await;
486 let delivery_id = insert_pending(&db, tenant, &webhook_id, 0).await;
487
488 let worker = WebhookWorker::new(db.pool().clone(), small_config());
489 let claims = worker.claim_due_batch().await.unwrap();
490 assert_eq!(claims.len(), 1);
491 dispatch_and_record(db.pool(), &worker.http, claims.into_iter().next().unwrap()).await;
492
493 let (status, attempts, code, next_retry) = delivery_row(&db, &delivery_id).await;
494 assert_eq!(status, "delivered");
495 assert_eq!(attempts, 0);
496 assert_eq!(code, Some(200));
497 assert!(next_retry.is_none());
498
499 let sig = captured.lock().unwrap().clone().expect("sig captured");
500 assert!(
501 sig.starts_with("t="),
502 "signature should be Stripe-style: {sig}"
503 );
504 assert!(sig.contains(",v1="), "signature should contain v1=: {sig}");
505 }
506
507 #[tokio::test]
510 async fn dispatch_records_500_as_retry_with_backoff() {
511 let server = MockServer::start().await;
512 Mock::given(method("POST"))
513 .and(path("/hook"))
514 .respond_with(ResponseTemplate::new(500))
515 .mount(&server)
516 .await;
517
518 let db = ControlDb::new(test_pool().await).await.unwrap();
519 let tenant = seed_tenant(&db, "acme").await;
520 let url = format!("{}/hook", server.uri());
521 let webhook_id = seed_webhook(&db, tenant, &url, "test-secret").await;
522 let delivery_id = insert_pending(&db, tenant, &webhook_id, 0).await;
523
524 let worker = WebhookWorker::new(db.pool().clone(), small_config());
525 let claims = worker.claim_due_batch().await.unwrap();
526 let before = Utc::now();
527 dispatch_and_record(db.pool(), &worker.http, claims.into_iter().next().unwrap()).await;
528 let after = Utc::now();
529
530 let (status, attempts, code, next_retry) = delivery_row(&db, &delivery_id).await;
531 assert_eq!(status, "pending");
532 assert_eq!(attempts, 1);
533 assert_eq!(code, Some(500));
534 let next = next_retry.expect("next_retry_at should be set");
535 let lower = before + chrono::Duration::seconds(55);
537 let upper = after + chrono::Duration::seconds(65);
538 assert!(
539 next >= lower && next <= upper,
540 "next={next} expected ~now+60s"
541 );
542 }
543
544 #[tokio::test]
547 async fn dispatch_marks_failed_after_retry_budget_exhausted() {
548 let server = MockServer::start().await;
549 Mock::given(method("POST"))
550 .and(path("/hook"))
551 .respond_with(ResponseTemplate::new(500))
552 .mount(&server)
553 .await;
554
555 let db = ControlDb::new(test_pool().await).await.unwrap();
556 let tenant = seed_tenant(&db, "acme").await;
557 let url = format!("{}/hook", server.uri());
558 let webhook_id = seed_webhook(&db, tenant, &url, "test-secret").await;
559 let delivery_id = insert_pending(&db, tenant, &webhook_id, 5).await;
561
562 let worker = WebhookWorker::new(db.pool().clone(), small_config());
563 let claims = worker.claim_due_batch().await.unwrap();
564 dispatch_and_record(db.pool(), &worker.http, claims.into_iter().next().unwrap()).await;
565
566 let (status, attempts, code, next_retry) = delivery_row(&db, &delivery_id).await;
567 assert_eq!(status, "failed");
568 assert_eq!(attempts, 6);
569 assert_eq!(code, Some(500));
570 assert!(next_retry.is_none(), "no further retry scheduled");
571 }
572
573 #[tokio::test]
576 async fn dispatch_records_transport_error_as_retry() {
577 let db = ControlDb::new(test_pool().await).await.unwrap();
578 let tenant = seed_tenant(&db, "acme").await;
579 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
582 let addr = listener.local_addr().unwrap();
583 drop(listener);
584 let url = format!("http://{addr}/hook");
585 let webhook_id = seed_webhook(&db, tenant, &url, "test-secret").await;
586 let delivery_id = insert_pending(&db, tenant, &webhook_id, 2).await;
587
588 let worker = WebhookWorker::new(db.pool().clone(), small_config());
589 let claims = worker.claim_due_batch().await.unwrap();
590 dispatch_and_record(db.pool(), &worker.http, claims.into_iter().next().unwrap()).await;
591
592 let (status, attempts, code, _next_retry) = delivery_row(&db, &delivery_id).await;
593 assert_eq!(status, "pending");
597 assert_eq!(attempts, 3);
598 assert_eq!(code, None, "transport error → response_code stays NULL");
599 }
600
601 #[tokio::test]
604 async fn claim_due_batch_respects_due_window_and_status_filter() {
605 let db = ControlDb::new(test_pool().await).await.unwrap();
606 let tenant = seed_tenant(&db, "acme").await;
607 let webhook_id = seed_webhook(&db, tenant, "https://example/hook", "s").await;
608
609 for _ in 0..3 {
611 insert_pending(&db, tenant, &webhook_id, 0).await;
612 }
613 let future_id = Uuid::now_v7();
617 sqlx::query(
618 "INSERT INTO webhook_deliveries \
619 (id, tenant_id, webhook_id, event_id, event_type, payload, status, attempts, next_retry_at) \
620 VALUES (?, ?, ?, 'evt', 'user.created', '{}', 'pending', 1, \
621 strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+1 hour'))",
622 )
623 .bind(future_id.as_bytes().as_ref())
624 .bind(tenant.as_bytes())
625 .bind(&webhook_id)
626 .execute(db.pool())
627 .await
628 .unwrap();
629 let done_id = Uuid::now_v7();
631 sqlx::query(
632 "INSERT INTO webhook_deliveries \
633 (id, tenant_id, webhook_id, event_id, event_type, payload, status, attempts) \
634 VALUES (?, ?, ?, 'evt2', 'user.created', '{}', 'delivered', 0)",
635 )
636 .bind(done_id.as_bytes().as_ref())
637 .bind(tenant.as_bytes())
638 .bind(&webhook_id)
639 .execute(db.pool())
640 .await
641 .unwrap();
642
643 let worker = WebhookWorker::new(db.pool().clone(), small_config());
644 let claims = worker.claim_due_batch().await.unwrap();
645 assert_eq!(
646 claims.len(),
647 3,
648 "only the three immediately-due rows are claimed"
649 );
650
651 let again = worker.claim_due_batch().await.unwrap();
653 assert!(again.is_empty(), "in_flight rows are not re-claimed");
654 }
655
656 #[tokio::test]
659 async fn worker_run_caps_in_flight_at_concurrency_limit() {
660 let in_flight = Arc::new(AtomicUsize::new(0));
663 let peak = Arc::new(AtomicUsize::new(0));
664 let server = MockServer::start().await;
665 {
666 let in_flight = in_flight.clone();
667 let peak = peak.clone();
668 Mock::given(method("POST"))
669 .and(path("/hook"))
670 .respond_with(move |_req: &wiremock::Request| {
671 let cur = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
672 let mut prev = peak.load(Ordering::SeqCst);
673 while cur > prev
674 && peak
675 .compare_exchange(prev, cur, Ordering::SeqCst, Ordering::SeqCst)
676 .is_err()
677 {
678 prev = peak.load(Ordering::SeqCst);
679 }
680 std::thread::sleep(Duration::from_millis(50));
682 in_flight.fetch_sub(1, Ordering::SeqCst);
683 ResponseTemplate::new(200)
684 })
685 .mount(&server)
686 .await;
687 }
688
689 let db = ControlDb::new(test_pool().await).await.unwrap();
690 let tenant = seed_tenant(&db, "acme").await;
691 let url = format!("{}/hook", server.uri());
692 let webhook_id = seed_webhook(&db, tenant, &url, "secret").await;
693 for _ in 0..20 {
694 insert_pending(&db, tenant, &webhook_id, 0).await;
695 }
696
697 let cfg = WebhookWorkerConfig {
698 poll_interval: Duration::from_millis(20),
699 batch_size: 32,
700 concurrency: 3, request_timeout: Duration::from_secs(2),
702 };
703 let worker = WebhookWorker::new(db.pool().clone(), cfg);
704 let (tx, rx) = watch::channel(false);
705
706 let handle = tokio::spawn(async move {
707 worker.run(rx).await;
708 });
709
710 for _ in 0..200 {
712 tokio::time::sleep(Duration::from_millis(50)).await;
713 let pending: i64 = sqlx::query_scalar(
714 "SELECT COUNT(*) FROM webhook_deliveries \
715 WHERE status IN ('pending','in_flight')",
716 )
717 .fetch_one(db.pool())
718 .await
719 .unwrap();
720 if pending == 0 {
721 break;
722 }
723 }
724
725 let _ = tx.send(true);
726 let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
727
728 let observed = peak.load(Ordering::SeqCst);
729 assert!(
730 observed <= 3,
731 "peak in-flight should never exceed concurrency=3 (observed: {observed})"
732 );
733 let delivered: i64 = sqlx::query_scalar(
734 "SELECT COUNT(*) FROM webhook_deliveries WHERE status = 'delivered'",
735 )
736 .fetch_one(db.pool())
737 .await
738 .unwrap();
739 assert_eq!(delivered, 20, "all 20 deliveries should reach delivered");
740 }
741
742 #[tokio::test]
759 async fn claim_due_batch_picks_up_rows_with_past_next_retry_at() {
760 let db = ControlDb::new(test_pool().await).await.unwrap();
765 let tenant = seed_tenant(&db, "acme").await;
766 let webhook_id = seed_webhook(&db, tenant, "https://example/hook", "s").await;
767
768 let due_id = Uuid::now_v7();
770 sqlx::query(
771 "INSERT INTO webhook_deliveries \
772 (id, tenant_id, webhook_id, event_id, event_type, payload, status, attempts, next_retry_at) \
773 VALUES (?, ?, ?, 'evt', 'user.created', '{}', 'pending', 1, \
774 strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 second'))",
775 )
776 .bind(due_id.as_bytes().as_ref())
777 .bind(tenant.as_bytes())
778 .bind(&webhook_id)
779 .execute(db.pool())
780 .await
781 .unwrap();
782
783 let worker = WebhookWorker::new(db.pool().clone(), small_config());
784 let claims = worker.claim_due_batch().await.unwrap();
785 assert_eq!(
786 claims.len(),
787 1,
788 "past-due row should claim via the `next_retry_at <= now` branch"
789 );
790 }
791
792 #[tokio::test]
793 async fn dispatched_signature_verifies_against_transmitted_body() {
794 let server = MockServer::start().await;
801 let captured: Arc<Mutex<Option<(String, Vec<u8>)>>> = Arc::new(Mutex::new(None));
802 {
803 let captured = captured.clone();
804 Mock::given(method("POST"))
805 .and(path("/hook"))
806 .respond_with(move |req: &wiremock::Request| {
807 let sig = req
808 .headers
809 .get("x-allowthem-signature")
810 .map(|v| v.to_str().unwrap().to_owned())
811 .unwrap_or_default();
812 *captured.lock().unwrap() = Some((sig, req.body.clone()));
813 ResponseTemplate::new(200)
814 })
815 .mount(&server)
816 .await;
817 }
818
819 let secret = "round-trip-secret";
820 let db = ControlDb::new(test_pool().await).await.unwrap();
821 let tenant = seed_tenant(&db, "acme").await;
822 let url = format!("{}/hook", server.uri());
823 let webhook_id = seed_webhook(&db, tenant, &url, secret).await;
824 let _delivery_id = insert_pending(&db, tenant, &webhook_id, 0).await;
825
826 let worker = WebhookWorker::new(db.pool().clone(), small_config());
827 let claim = worker
828 .claim_due_batch()
829 .await
830 .unwrap()
831 .into_iter()
832 .next()
833 .unwrap();
834 dispatch_and_record(db.pool(), &worker.http, claim).await;
835
836 let (sig, body) = captured.lock().unwrap().clone().expect("request captured");
837 let now = Utc::now().timestamp();
841 allowthem_core::webhook_sig::verify_payload(secret.as_bytes(), &body, &sig, now, 60)
842 .expect("signature should verify against the transmitted body and seeded secret");
843 }
844
845 #[tokio::test]
846 async fn claim_marks_failed_when_subscription_deleted_between_claim_and_fetch() {
847 let db = ControlDb::new(test_pool().await).await.unwrap();
852 let tenant = seed_tenant(&db, "acme").await;
853 let webhook_id = seed_webhook(&db, tenant, "https://example/hook", "s").await;
854 let delivery_id = insert_pending(&db, tenant, &webhook_id, 0).await;
855
856 let mut conn = db.pool().acquire().await.unwrap();
865 sqlx::query("PRAGMA foreign_keys = OFF")
866 .execute(&mut *conn)
867 .await
868 .unwrap();
869 sqlx::query("DELETE FROM tenant_webhooks WHERE id = ?")
870 .bind(&webhook_id)
871 .execute(&mut *conn)
872 .await
873 .unwrap();
874 sqlx::query("PRAGMA foreign_keys = ON")
875 .execute(&mut *conn)
876 .await
877 .unwrap();
878 drop(conn);
879
880 let worker = WebhookWorker::new(db.pool().clone(), small_config());
881 let claims = worker.claim_due_batch().await.unwrap();
882 assert!(
883 claims.is_empty(),
884 "claim_due_batch should not return rows whose subscription is gone"
885 );
886
887 let (status, _attempts, _code, _next) = delivery_row(&db, &delivery_id).await;
888 assert_eq!(
889 status, "failed",
890 "delivery should be marked failed so it isn't claimed again"
891 );
892
893 let again = worker.claim_due_batch().await.unwrap();
895 assert!(again.is_empty());
896 }
897
898 #[tokio::test]
899 async fn run_terminates_promptly_on_shutdown_signal() {
900 let db = ControlDb::new(test_pool().await).await.unwrap();
904 let cfg = WebhookWorkerConfig {
905 poll_interval: Duration::from_millis(50),
906 batch_size: 8,
907 concurrency: 4,
908 request_timeout: Duration::from_secs(1),
909 };
910 let worker = WebhookWorker::new(db.pool().clone(), cfg);
911 let (tx, rx) = watch::channel(false);
912
913 let handle = tokio::spawn(async move {
914 worker.run(rx).await;
915 });
916
917 tokio::time::sleep(Duration::from_millis(150)).await;
920 assert!(!handle.is_finished(), "worker should still be running");
921
922 let _ = tx.send(true);
923 let result = tokio::time::timeout(Duration::from_millis(500), handle).await;
924 assert!(
925 result.is_ok(),
926 "worker did not exit within 500 ms of shutdown signal"
927 );
928 }
929}