Skip to main content

allowthem_saas/
webhook_worker.rs

1//! Background webhook delivery worker.
2//!
3//! Polls `webhook_deliveries` for due rows, claims them via
4//! `UPDATE ... RETURNING`, signs the payload via the shared
5//! [`allowthem_core::webhook_sig::sign_payload`] helper, POSTs to the
6//! subscriber, and records the outcome (success → `delivered`; transport
7//! or non-2xx error → re-schedule with exponential backoff or `failed`
8//! after the 5th retry).
9//!
10//! Single-instance — the saas binary runs one worker. Multi-host HA is a
11//! v2 concern (see saas-mode-design §11). The schema-level claim is
12//! race-free under SQLite's serialized writers, so a future second
13//! worker would not double-deliver, but the loop logic assumes one.
14//!
15//! Lifecycle: `run(shutdown_rx)` blocks until the supplied
16//! `tokio::sync::watch` receiver flips to `true`. The saas binary
17//! constructs the channel, spawns `run`, and signals shutdown on Ctrl-C
18//! / SIGTERM.
19
20use 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/// Tunable configuration for [`WebhookWorker`]. Defaults are suitable for
31/// the SaaS dev binary; production may tune `concurrency` and
32/// `request_timeout` upward.
33#[derive(Debug, Clone)]
34pub struct WebhookWorkerConfig {
35    /// Time between consecutive `claim_due_batch` polls when the previous
36    /// batch returned zero rows. Default: 5 s.
37    pub poll_interval: Duration,
38    /// Maximum number of due rows claimed per poll. Default: 64.
39    pub batch_size: i64,
40    /// Maximum number of in-flight HTTP requests at any time. Default: 10.
41    pub concurrency: usize,
42    /// HTTP request timeout per delivery attempt. Default: 10 s.
43    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
57/// Background webhook delivery loop.
58pub struct WebhookWorker {
59    pool: SqlitePool,
60    http: reqwest::Client,
61    config: WebhookWorkerConfig,
62    sem: Arc<Semaphore>,
63}
64
65impl WebhookWorker {
66    /// Construct a worker. Wraps a `reqwest::Client` with the configured
67    /// timeout. Panics if reqwest fails to construct the client (e.g. TLS
68    /// backend unavailable) — a deployment configuration bug, not a
69    /// runtime error.
70    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    /// Run the polling loop until `shutdown` flips to `true`. Returns when
86    /// shutdown is signalled and the in-flight permit holders have either
87    /// completed or been dropped.
88    pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
89        loop {
90            // Cheap shutdown check before the (potentially slow) DB poll.
91            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, // semaphore closed → shutting down
101                        };
102                        let pool = self.pool.clone();
103                        let http = self.http.clone();
104                        tokio::spawn(async move {
105                            // permit dropped at end of scope releases the slot.
106                            dispatch_and_record(&pool, &http, claim).await;
107                            drop(permit);
108                        });
109                    }
110                    // Loop back immediately — there may be more due rows.
111                }
112                Ok(_) => {
113                    // No work — sleep until the next tick or shutdown.
114                    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                    // Brief pause before retrying so a persistent DB error
122                    // doesn't tight-loop the log.
123                    tokio::select! {
124                        _ = sleep(self.config.poll_interval) => {}
125                        _ = shutdown.changed() => {}
126                    }
127                }
128            }
129        }
130    }
131
132    /// Atomically claim up to `batch_size` due rows by transitioning them
133    /// from `pending` to `in_flight`. Returns the claimed deliveries
134    /// joined with their subscription's url + secret_key.
135    async fn claim_due_batch(&self) -> Result<Vec<ClaimedDelivery>, sqlx::Error> {
136        // Two-step claim: an UPDATE ... RETURNING grabs the rows we own,
137        // then a JOIN query fetches the subscription details. Doing the
138        // join inside the UPDATE is awkward in SQLite because the inner
139        // SELECT can't be a JOIN that depends on the outer table; the
140        // separate fetch is simpler and cheap (one indexed lookup per id).
141        #[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        // Fetch (url, secret, payload) for each claimed delivery.
165        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                    // Webhook deleted between claim and fetch. Mark the
181                    // delivery as failed so it doesn't loop forever.
182                    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/// One claimed row, ready to be dispatched.
210#[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
220/// Compute the next retry delay for a failure. Returns `None` when the
221/// retry budget (5 retries / 6 total attempts) is exhausted; the caller
222/// then marks the row `failed`.
223///
224/// `attempts_after_failure` is the value of `attempts` *after*
225/// incrementing it for this failure — so the first failure passes 1.
226fn 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// ---------------------------------------------------------------------------
343// Tests
344// ---------------------------------------------------------------------------
345
346#[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    // -- backoff math -------------------------------------------------------
359
360    #[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    // -- Helpers (mirroring webhook_sink::tests; kept inline so this module
372    // is self-contained) -----------------------------------------------------
373
374    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    // -- Happy path: 200 → delivered ----------------------------------------
461
462    #[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    // -- 500 → pending, attempts +1, next_retry_at scheduled ----------------
508
509    #[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        // Should be ~60 seconds in the future (retry #1).
536        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    // -- attempts=5 + 500 → failed ------------------------------------------
545
546    #[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        // attempts=5 means this is attempt #6 and will be the last.
560        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    // -- Transport error (connection refused) → pending + retry -------------
574
575    #[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        // Bind a TCP socket and immediately drop to get a guaranteed
580        // unreachable port (well, almost — short race window OK for tests).
581        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        // Either we hit the transport error path (response_code = None) or
594        // the OS races and accepts on a different socket; only the
595        // transport error has stable semantics, so assert that.
596        assert_eq!(status, "pending");
597        assert_eq!(attempts, 3);
598        assert_eq!(code, None, "transport error → response_code stays NULL");
599    }
600
601    // -- claim_due_batch is race-free under contention ----------------------
602
603    #[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        // 3 pending rows immediately due (next_retry_at IS NULL).
610        for _ in 0..3 {
611            insert_pending(&db, tenant, &webhook_id, 0).await;
612        }
613        // 1 pending row with next_retry_at in the future — should not be
614        // claimed. Use the same ISO 8601 format the worker compares
615        // against, otherwise lexicographic comparison breaks.
616        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        // 1 already-delivered row.
630        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        // Subsequent claim returns nothing — claimed rows are now in_flight.
652        let again = worker.claim_due_batch().await.unwrap();
653        assert!(again.is_empty(), "in_flight rows are not re-claimed");
654    }
655
656    // -- Concurrency cap -----------------------------------------------------
657
658    #[tokio::test]
659    async fn worker_run_caps_in_flight_at_concurrency_limit() {
660        // Server delays each response so we can observe overlap. Track the
661        // peak in-flight count and assert it never exceeds the cap.
662        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                    // Hold the slot a bit so concurrent fires actually overlap.
681                    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, // cap to 3
701            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        // Wait for all 20 to be drained.
711        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    // -- Gap-filling regressions (7xw.2.4) ---------------------------------
743    //
744    // Each of the following targets a behavior the production code has but
745    // the original test set did not exercise:
746    //
747    // - past-due `next_retry_at` triggers re-claim (the OR branch in the
748    //   claim WHERE clause)
749    // - signature is HMAC over the actual transmitted body (closes the
750    //   sign/verify contract round-trip)
751    // - webhook deletion between claim and fetch flips delivery to `failed`
752    //   instead of looping (the `None` arm in `claim_due_batch`)
753    // - shutdown signal terminates `run()` promptly (the
754    //   `tokio::select!` shutdown branch)
755    // - pool failure inside `WebhookEventSink::emit` is logged and
756    //   swallowed — the auth path never sees the error
757
758    #[tokio::test]
759    async fn claim_due_batch_picks_up_rows_with_past_next_retry_at() {
760        // The claim WHERE clause is `next_retry_at IS NULL OR next_retry_at
761        // <= now`. The other claim test only exercises NULL + future; this
762        // one covers the past branch — a row that failed earlier and is now
763        // due for retry should be picked up.
764        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        // Pending row with next_retry_at one second ago — should claim.
769        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        // Catches the bug class "signed material doesn't match POST body".
795        // The 200 happy-path test only checks the header *shape*; this test
796        // captures the actual transmitted body and runs core::webhook_sig::
797        // verify_payload against it with the seeded secret. A regression
798        // that swapped HMAC inputs or signed a different blob would still
799        // produce a `t=…,v1=…` header but fail this assertion.
800        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        // verify_payload uses the same scheme as sign_payload; round-trip
838        // succeeds iff the worker signed the exact bytes it sent and used
839        // the configured secret.
840        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        // Exercises the `None` arm in `claim_due_batch`'s post-claim fetch:
848        // if `tenant_webhooks` row is gone (operator deleted between this
849        // delivery being inserted and being claimed) the delivery flips to
850        // `failed` rather than looping forever.
851        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        // Delete the parent webhook *before* claim. ON DELETE CASCADE on
857        // webhook_deliveries.webhook_id would normally drop the row too;
858        // disable foreign keys for this single statement so we can simulate
859        // the brief race between claim and fetch.
860        //
861        // `PRAGMA foreign_keys` is connection-scoped, so all three
862        // statements must share one connection — running them through the
863        // pool would land on different connections and the cascade fires.
864        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        // And confirm: a second claim returns nothing — no infinite loop.
894        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        // Exercises the `tokio::select!` shutdown branches in `run()`. The
901        // worker should observe the `watch::Sender::send(true)` and exit
902        // within a couple of poll intervals, not stay alive indefinitely.
903        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        // Let the loop tick at least once so we know it is parked on the
918        // poll-interval select arm.
919        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}