Skip to main content

autumn_web/
webhook_outbound.rs

1#![allow(
2    clippy::significant_drop_tightening,
3    clippy::missing_panics_doc,
4    clippy::missing_errors_doc
5)]
6//! Outbound signed webhook delivery with retries, DLQ, and subscription management.
7
8// autumn-determinism-gate: production code in this module must read time and
9// mint identifiers through the framework's injected seams (ClockSource /
10// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
11// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
12// (issue #1797). Justify exceptions with
13// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
14#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use std::future::Future;
20use std::pin::Pin;
21use std::sync::{Arc, RwLock};
22
23use crate::http_client::Client;
24use crate::{AppState, AutumnError, AutumnResult};
25
26const MAX_LOGGED_RESPONSE_BODY_BYTES: usize = 16 * 1024;
27const TRUNCATED_RESPONSE_BODY_SUFFIX: &str = "\n[truncated]";
28
29/// The status of a webhook subscription.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum WebhookSubscriptionStatus {
33    Active,
34    Disabled,
35    Failed,
36}
37
38impl WebhookSubscriptionStatus {
39    /// Return the lower-case status label.
40    #[must_use]
41    pub const fn as_str(self) -> &'static str {
42        match self {
43            Self::Active => "active",
44            Self::Disabled => "disabled",
45            Self::Failed => "failed",
46        }
47    }
48}
49
50impl std::fmt::Display for WebhookSubscriptionStatus {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.write_str(self.as_str())
53    }
54}
55
56/// A registered webhook subscription targeting a consumer endpoint.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct WebhookSubscription {
59    pub id: String,
60    pub target_url: String,
61    pub event_topics: Vec<String>,
62    pub secret: String,
63    pub status: WebhookSubscriptionStatus,
64    pub consecutive_failures: u32,
65}
66
67/// A structured log of an outbound webhook delivery attempt.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct WebhookDeliveryLog {
70    pub id: String,
71    pub subscription_id: String,
72    pub topic: String,
73    pub payload: String,
74    pub request_headers: HashMap<String, String>,
75    pub response_status: Option<u16>,
76    pub response_body: Option<String>,
77    pub elapsed_ms: u64,
78    pub attempt: u32,
79    pub max_attempts: u32,
80    pub is_dlq: bool,
81    pub last_error: Option<String>,
82    pub timestamp: DateTime<Utc>,
83}
84
85/// `true` when [`OutboundWebhookHandler::log_delivery`] must ignore `new`:
86///
87/// - `stored` has a 2xx response. A success is final.
88/// - `new` is not a 2xx and comes from a duplicate job: `stored` has an
89///   outcome for the same attempt and the same DLQ flag, or `stored` is a
90///   later attempt. A replay reset (out of the DLQ, back to a pending
91///   attempt 1) is not stale. A 2xx always replaces a failure.
92#[must_use]
93pub fn log_delivery_ignores(stored: &WebhookDeliveryLog, new: &WebhookDeliveryLog) -> bool {
94    let is_success = |log: &WebhookDeliveryLog| {
95        log.response_status
96            .is_some_and(|status| (200..300).contains(&status))
97    };
98    let has_outcome =
99        |log: &WebhookDeliveryLog| log.response_status.is_some() || log.last_error.is_some();
100    let repeat =
101        has_outcome(stored) && stored.attempt == new.attempt && stored.is_dlq == new.is_dlq;
102    let leaves_dlq = stored.is_dlq && !new.is_dlq && !has_outcome(new);
103    let stale = new.attempt < stored.attempt && !leaves_dlq;
104    is_success(stored) || (!is_success(new) && (repeat || stale))
105}
106
107/// `true` when `new` is a failure that `stored` already counted: `stored` has
108/// an outcome for the same attempt. A DLQ move of a failed attempt is stored,
109/// but it is not a new failure.
110#[must_use]
111pub fn log_delivery_repeats_failure(
112    stored: Option<&WebhookDeliveryLog>,
113    new: &WebhookDeliveryLog,
114) -> bool {
115    let is_success = new
116        .response_status
117        .is_some_and(|status| (200..300).contains(&status));
118    !is_success
119        && stored.is_some_and(|stored| {
120            (stored.response_status.is_some() || stored.last_error.is_some())
121                && stored.attempt == new.attempt
122        })
123}
124
125/// Pluggable handler interface for outbound webhook subscriptions and delivery logs.
126pub trait OutboundWebhookHandler: Send + Sync + 'static {
127    /// Retrieve active subscriptions registered for a specific event topic.
128    fn get_subscriptions(
129        &self,
130        topic: &str,
131    ) -> Pin<Box<dyn Future<Output = AutumnResult<Vec<WebhookSubscription>>> + Send>>;
132
133    /// Log a webhook delivery attempt and handle failure counters/statuses.
134    ///
135    /// Two jobs can send one delivery (an outbox re-send). Ignore a write
136    /// that [`log_delivery_ignores`] names: a write to a 2xx log, or a repeated
137    /// or stale failure. Then a late duplicate job cannot
138    /// overwrite a success or count one failure twice. Do not count a failure
139    /// that [`log_delivery_repeats_failure`] names, for example a DLQ move.
140    fn log_delivery(
141        &self,
142        log: WebhookDeliveryLog,
143    ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>>;
144
145    /// Replace a stored delivery log without treating it as a new delivery outcome.
146    ///
147    /// Implementations must perform a plain record replacement. This must not
148    /// update subscription failure counters or auto-failure state.
149    fn replace_delivery_log(
150        &self,
151        log: WebhookDeliveryLog,
152    ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>>;
153
154    /// Retrieve a specific webhook subscription by ID (regardless of status/active state).
155    fn get_subscription(
156        &self,
157        id: &str,
158    ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookSubscription>>> + Send>>;
159
160    /// Optional: List only permanently failed delivery attempts archived in the Dead Letter Queue.
161    fn get_dlq_logs(
162        &self,
163    ) -> Pin<Box<dyn Future<Output = AutumnResult<Vec<WebhookDeliveryLog>>> + Send>> {
164        Box::pin(async { Ok(Vec::new()) })
165    }
166
167    /// Get a specific delivery log by ID.
168    fn get_delivery_log(
169        &self,
170        id: &str,
171    ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookDeliveryLog>>> + Send>>;
172
173    /// Optional: Reset consecutive failures for a subscription.
174    fn reset_subscription_failures(
175        &self,
176        _id: &str,
177    ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
178        Box::pin(async { Ok(()) })
179    }
180
181    /// Optional: Reactivate a subscription that was auto-marked as failed.
182    ///
183    /// Manual DLQ replays need to bypass the automatic failure guard without
184    /// re-enabling subscriptions that an operator explicitly disabled.
185    fn reactivate_failed_subscription(
186        &self,
187        id: &str,
188    ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
189        self.reset_subscription_failures(id)
190    }
191}
192
193/// Legacy alias for backward compatibility.
194pub use OutboundWebhookHandler as OutboundWebhookStore;
195
196#[cfg(feature = "db")]
197mod sql_store;
198#[cfg(feature = "db")]
199pub use sql_store::{SqlOutboundWebhookStore, WEBHOOK_SCHEMA_SQL};
200
201/// Bounded, thread-safe, process-local in-memory implementation of the outbound webhook handler.
202#[derive(Debug, Default)]
203pub struct InMemoryOutboundWebhookHandler {
204    subscriptions: RwLock<HashMap<String, WebhookSubscription>>,
205    logs: RwLock<HashMap<String, WebhookDeliveryLog>>,
206}
207
208/// Legacy alias for backward compatibility.
209pub type InMemoryOutboundWebhookStore = InMemoryOutboundWebhookHandler;
210
211impl InMemoryOutboundWebhookHandler {
212    /// Create a new, empty in-memory handler.
213    #[must_use]
214    pub fn new() -> Self {
215        Self::default()
216    }
217
218    /// Helper to register a subscription in memory for testing/dev.
219    #[allow(clippy::unused_async)]
220    pub async fn create_subscription(
221        &self,
222        sub: WebhookSubscription,
223    ) -> AutumnResult<WebhookSubscription> {
224        let mut subs = self
225            .subscriptions
226            .write()
227            .expect("subscriptions write lock poisoned");
228        subs.insert(sub.id.clone(), sub.clone());
229        Ok(sub)
230    }
231
232    /// Helper to retrieve logged deliveries for testing/dev.
233    #[allow(clippy::unused_async)]
234    pub async fn get_delivery_logs(&self) -> AutumnResult<Vec<WebhookDeliveryLog>> {
235        let logs = self.logs.read().expect("logs read lock poisoned");
236        let mut list: Vec<WebhookDeliveryLog> = logs.values().cloned().collect();
237        list.sort_by_key(|l| l.timestamp);
238        list.reverse();
239        Ok(list)
240    }
241
242    /// Helper to fetch a single subscription.
243    #[allow(clippy::unused_async)]
244    pub async fn get_subscription(&self, id: &str) -> AutumnResult<Option<WebhookSubscription>> {
245        let subs = self
246            .subscriptions
247            .read()
248            .expect("subscriptions read lock poisoned");
249        Ok(subs.get(id).cloned())
250    }
251}
252
253impl OutboundWebhookHandler for InMemoryOutboundWebhookHandler {
254    fn get_subscriptions(
255        &self,
256        topic: &str,
257    ) -> Pin<Box<dyn Future<Output = AutumnResult<Vec<WebhookSubscription>>> + Send>> {
258        let subs = self
259            .subscriptions
260            .read()
261            .expect("subscriptions read lock poisoned");
262        let topic = topic.to_owned();
263        let list: Vec<WebhookSubscription> = subs
264            .values()
265            .filter(|sub| {
266                sub.event_topics.iter().any(|t| t == &topic)
267                    && sub.status == WebhookSubscriptionStatus::Active
268            })
269            .cloned()
270            .collect();
271        Box::pin(async move { Ok(list) })
272    }
273
274    fn get_subscription(
275        &self,
276        id: &str,
277    ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookSubscription>>> + Send>> {
278        let subs = self
279            .subscriptions
280            .read()
281            .expect("subscriptions read lock poisoned");
282        let sub = subs.get(id).cloned();
283        Box::pin(async move { Ok(sub) })
284    }
285
286    fn log_delivery(
287        &self,
288        log: WebhookDeliveryLog,
289    ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
290        let mut logs = self.logs.write().expect("logs write lock poisoned");
291        if logs
292            .get(&log.id)
293            .is_some_and(|stored| log_delivery_ignores(stored, &log))
294        {
295            return Box::pin(async { Ok(()) });
296        }
297        let counted = log_delivery_repeats_failure(logs.get(&log.id), &log);
298        logs.insert(log.id.clone(), log.clone());
299
300        // Manage subscription consecutive failures and auto-disabling state
301        let mut subs = self
302            .subscriptions
303            .write()
304            .expect("subscriptions write lock poisoned");
305        if let Some(sub) = subs.get_mut(&log.subscription_id)
306            && sub.status == WebhookSubscriptionStatus::Active
307        {
308            let is_success = log
309                .response_status
310                .is_some_and(|status| (200..300).contains(&status));
311            let is_failure = log.response_status.is_some() || log.last_error.is_some();
312            if is_success {
313                sub.consecutive_failures = 0;
314            } else if is_failure && !counted {
315                sub.consecutive_failures = sub.consecutive_failures.saturating_add(1);
316                if sub.consecutive_failures >= 50 {
317                    sub.status = WebhookSubscriptionStatus::Failed;
318                    tracing::warn!(subscription_id = %sub.id, "Webhook subscription auto-disabled due to 50 consecutive failures");
319                }
320            }
321        }
322
323        Box::pin(async move { Ok(()) })
324    }
325
326    fn replace_delivery_log(
327        &self,
328        log: WebhookDeliveryLog,
329    ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
330        let mut logs = self.logs.write().expect("logs write lock poisoned");
331        logs.insert(log.id.clone(), log);
332        Box::pin(async move { Ok(()) })
333    }
334
335    fn get_dlq_logs(
336        &self,
337    ) -> Pin<Box<dyn Future<Output = AutumnResult<Vec<WebhookDeliveryLog>>> + Send>> {
338        let list = {
339            let logs = self.logs.read().expect("logs read lock poisoned");
340            let mut list: Vec<WebhookDeliveryLog> =
341                logs.values().filter(|l| l.is_dlq).cloned().collect();
342            list.sort_by_key(|l| l.timestamp);
343            list.reverse();
344            list
345        };
346        Box::pin(async move { Ok(list) })
347    }
348
349    fn get_delivery_log(
350        &self,
351        id: &str,
352    ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookDeliveryLog>>> + Send>> {
353        let log = self
354            .logs
355            .read()
356            .expect("logs read lock poisoned")
357            .get(id)
358            .cloned();
359        Box::pin(async move { Ok(log) })
360    }
361
362    fn reset_subscription_failures(
363        &self,
364        id: &str,
365    ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
366        {
367            let mut subs = self
368                .subscriptions
369                .write()
370                .expect("subscriptions write lock poisoned");
371            if let Some(sub) = subs.get_mut(id) {
372                sub.consecutive_failures = 0;
373            }
374        }
375        Box::pin(async move { Ok(()) })
376    }
377
378    fn reactivate_failed_subscription(
379        &self,
380        id: &str,
381    ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
382        {
383            let mut subs = self
384                .subscriptions
385                .write()
386                .expect("subscriptions write lock poisoned");
387            if let Some(sub) = subs.get_mut(id) {
388                sub.consecutive_failures = 0;
389                if sub.status == WebhookSubscriptionStatus::Failed {
390                    sub.status = WebhookSubscriptionStatus::Active;
391                }
392            }
393        }
394        Box::pin(async move { Ok(()) })
395    }
396}
397
398/// A runtime delegation callback type to bridge core autumn to autumn-harvest dynamically.
399pub type WebhookDelegate = Arc<
400    dyn Fn(
401            &AppState,
402            WebhookSubscription,
403            WebhookDeliveryLog,
404        ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>>
405        + Send
406        + Sync,
407>;
408
409/// `AppState` extension for the runtime delegation hook.
410#[derive(Clone)]
411pub struct WebhookDelegateExt(pub WebhookDelegate);
412
413/// The runtime manager for outbound webhooks.
414#[derive(Clone)]
415pub struct WebhookOutboundManager {
416    handler: Arc<dyn OutboundWebhookHandler>,
417    client: Client,
418    initial_backoff_ms: u64,
419    max_attempts: u32,
420}
421
422/// Default number of delivery attempts for one webhook.
423pub const DEFAULT_WEBHOOK_MAX_ATTEMPTS: u32 = 5;
424
425impl WebhookOutboundManager {
426    /// Create a new webhook manager with a handler.
427    pub fn new(handler: Arc<dyn OutboundWebhookHandler>) -> Self {
428        Self {
429            handler,
430            client: Client::new(),
431            initial_backoff_ms: 1000,
432            max_attempts: DEFAULT_WEBHOOK_MAX_ATTEMPTS,
433        }
434    }
435
436    /// Set the number of delivery attempts per webhook. Default: 5.
437    /// A value of 0 is read as 1. The last failed attempt moves the delivery
438    /// to the DLQ.
439    ///
440    /// This does not change the `autumn_webhook_delivery` job. That job needs
441    /// 2 job attempts per delivery attempt. Use
442    /// [`OutboundWebhookPlugin::with_max_attempts`], which sets both.
443    #[must_use]
444    pub const fn with_max_attempts(mut self, max_attempts: u32) -> Self {
445        self.max_attempts = if max_attempts == 0 { 1 } else { max_attempts };
446        self
447    }
448
449    /// The number of delivery attempts per webhook.
450    #[must_use]
451    pub const fn max_attempts(&self) -> u32 {
452        self.max_attempts
453    }
454
455    /// Set a custom initial backoff for retries.
456    #[must_use]
457    pub const fn with_initial_backoff_ms(mut self, ms: u64) -> Self {
458        self.initial_backoff_ms = ms;
459        self
460    }
461
462    fn with_client_from_state(mut self, state: &AppState) -> Self {
463        self.client = Client::from_state(state);
464        self
465    }
466
467    /// Access the underlying webhook handler (compatibility/actuator support).
468    #[must_use]
469    pub fn store(&self) -> &Arc<dyn OutboundWebhookHandler> {
470        &self.handler
471    }
472
473    /// Access the underlying http client.
474    #[must_use]
475    pub const fn client(&self) -> &Client {
476        &self.client
477    }
478
479    /// Dispatch a signed webhook payload to all subscriptions interested in `topic`.
480    ///
481    /// # Errors
482    ///
483    /// Returns [`AutumnError`] if payload serialization or queueing fails.
484    pub async fn dispatch<T: Serialize + Sync>(
485        &self,
486        state: &AppState,
487        topic: &str,
488        payload: &T,
489    ) -> AutumnResult<()> {
490        self.dispatch_inner(state, topic, payload, None).await
491    }
492
493    /// Dispatch through the transactional outbox, on the connection of the
494    /// open transaction. Returns the outbox message id.
495    ///
496    /// The relay calls [`dispatch`](Self::dispatch) after commit. See
497    /// [`Outbox::dispatch_webhook`](crate::outbox::Outbox::dispatch_webhook).
498    ///
499    /// # Errors
500    ///
501    /// Returns [`AutumnError`] if serialization or the outbox insert fails.
502    #[cfg(feature = "db")]
503    pub async fn dispatch_in_tx<T: Serialize + Sync + ?Sized>(
504        &self,
505        state: &AppState,
506        conn: &mut crate::db::RuntimeConnection,
507        topic: &str,
508        payload: &T,
509    ) -> AutumnResult<String> {
510        crate::outbox::Outbox::new(state)
511            .dispatch_webhook(conn, topic, payload)
512            .await
513    }
514
515    /// Dispatch for outbox message `message_id`.
516    ///
517    /// Each delivery id comes from the message id and the subscription id.
518    /// A second call for one message skips each delivery that has a result.
519    /// It enqueues a delivery with no result again, with the same id.
520    #[cfg(feature = "db")]
521    pub(crate) async fn dispatch_for_message(
522        &self,
523        state: &AppState,
524        message_id: &str,
525        topic: &str,
526        payload: &serde_json::Value,
527    ) -> AutumnResult<()> {
528        self.dispatch_inner(state, topic, payload, Some(message_id))
529            .await
530    }
531
532    async fn dispatch_inner<T: Serialize + Sync + ?Sized>(
533        &self,
534        state: &AppState,
535        topic: &str,
536        payload: &T,
537        message_id: Option<&str>,
538    ) -> AutumnResult<()> {
539        let serialized = serde_json::to_string(payload).map_err(|e| {
540            AutumnError::internal_server_error_msg(format!("failed to serialize payload: {e}"))
541        })?;
542
543        let mut errors = Vec::new();
544        let subs = self.handler.get_subscriptions(topic).await?;
545        for sub in subs {
546            if sub.status == WebhookSubscriptionStatus::Disabled {
547                continue;
548            }
549
550            // A relay re-send finds the log of its first send. If a delivery
551            // attempt started, the job exists: skip. If not, the first send
552            // can have stopped before the enqueue: enqueue again. The copy
553            // has the same `webhook-id`, so the receiver can drop it.
554            let mut existing = None;
555            let log_id = match message_id {
556                Some(message_id) => {
557                    let log_id = delivery_id_for_message(message_id, &sub.id);
558                    if let Some(log) = self.handler.get_delivery_log(&log_id).await? {
559                        if log.response_status.is_some() || log.last_error.is_some() || log.is_dlq {
560                            continue;
561                        }
562                        existing = Some(log);
563                    }
564                    log_id
565                }
566                None => state.entropy().uuid_v4().to_string(),
567            };
568            let log = existing.clone().unwrap_or_else(|| WebhookDeliveryLog {
569                id: log_id.clone(),
570                subscription_id: sub.id.clone(),
571                topic: topic.to_owned(),
572                payload: serialized.clone(),
573                request_headers: HashMap::new(),
574                response_status: None,
575                response_body: None,
576                elapsed_ms: 0,
577                attempt: 1,
578                max_attempts: self.max_attempts,
579                is_dlq: false,
580                last_error: None,
581                timestamp: crate::time::ambient_now(),
582            });
583
584            // Register the initial attempt in local storage
585            if existing.is_none()
586                && let Err(e) = self.handler.log_delivery(log.clone()).await
587            {
588                errors.push(e);
589                continue;
590            }
591
592            // If a delegate extension is registered, run it (delegates to Harvest workflow)
593            if let Some(delegate_ext) = state.extension::<WebhookDelegateExt>() {
594                tracing::info!(subscription_id = %sub.id, "WebhookOutboundManager::dispatch: delegating webhook delivery via runtime hook");
595                if let Err(e) = (delegate_ext.0)(state, sub, log).await {
596                    errors.push(e);
597                }
598            } else {
599                // Fallback: enqueue a standard background job
600                tracing::debug!(subscription_id = %sub.id, "WebhookOutboundManager::dispatch: enqueuing fallback webhook delivery job");
601                if let Some(job_client) = crate::job::global_job_client() {
602                    let job_payload = serde_json::json!({
603                        "log_id": log.id.clone(),
604                    });
605                    if let Err(e) = job_client
606                        .enqueue("autumn_webhook_delivery", job_payload)
607                        .await
608                    {
609                        errors.push(self.enqueue_failed(log, e.to_string(), message_id).await);
610                    }
611                } else {
612                    errors.push(
613                        self.enqueue_failed(
614                            log,
615                            "Global job client is unavailable; fallback webhook delivery job not enqueued"
616                                .to_owned(),
617                            message_id,
618                        )
619                        .await,
620                    );
621                }
622            }
623        }
624
625        if !errors.is_empty() {
626            return Err(errors.remove(0));
627        }
628
629        Ok(())
630    }
631
632    /// An enqueue failed. From the outbox, keep the log pending and return
633    /// the error: the relay sends the message again, and the next send
634    /// enqueues the delivery. Else, move the log to the DLQ.
635    async fn enqueue_failed(
636        &self,
637        log: WebhookDeliveryLog,
638        message: String,
639        message_id: Option<&str>,
640    ) -> AutumnError {
641        if message_id.is_some() {
642            return AutumnError::internal_server_error_msg(message);
643        }
644        self.record_delivery_enqueue_failure(log, message).await
645    }
646
647    async fn record_delivery_enqueue_failure(
648        &self,
649        mut log: WebhookDeliveryLog,
650        message: String,
651    ) -> AutumnError {
652        log.is_dlq = true;
653        log.last_error = Some(message.clone());
654        log.timestamp = crate::time::ambient_now();
655
656        if let Err(e) = self.handler.replace_delivery_log(log).await {
657            tracing::error!(
658                error = %e,
659                "Failed to mark webhook delivery log as DLQ after enqueue failure"
660            );
661            return e;
662        }
663
664        AutumnError::internal_server_error_msg(message)
665    }
666}
667
668/// A delivery id made from an outbox message and a subscription. It has the
669/// same UUID format as a random delivery id.
670fn delivery_id_for_message(message_id: &str, subscription_id: &str) -> String {
671    use sha2::Digest as _;
672    let digest = sha2::Sha256::new()
673        .chain_update(b"autumn.webhook.delivery\0")
674        .chain_update(message_id.as_bytes())
675        .chain_update(b"\0")
676        .chain_update(subscription_id.as_bytes())
677        .finalize();
678    let mut bytes = [0_u8; 16];
679    bytes.copy_from_slice(&digest[..16]);
680    crate::entropy::uuid_v4_from_bytes(bytes).to_string()
681}
682
683fn install_outbound_webhook_manager(
684    state: &AppState,
685    store: Arc<dyn OutboundWebhookHandler>,
686    initial_backoff_ms: u64,
687    max_attempts: u32,
688) {
689    let manager = WebhookOutboundManager::new(store)
690        .with_initial_backoff_ms(initial_backoff_ms)
691        .with_max_attempts(max_attempts)
692        .with_client_from_state(state);
693    state.insert_extension(manager);
694}
695
696/// The `webhook-signature` value of the Standard Webhooks spec: `v1,` and the
697/// base64 HMAC-SHA256 of `{id}.{timestamp}.{body}`.
698///
699/// The spec keys the HMAC with the base64 part of a `whsec_` secret. Other
700/// secrets key it with their raw bytes.
701fn standard_webhooks_signature(secret: &str, id: &str, timestamp: i64, body: &str) -> String {
702    use base64::Engine as _;
703    use hmac::Mac as _;
704    let engine = base64::engine::general_purpose::STANDARD;
705    let key = secret
706        .strip_prefix("whsec_")
707        .and_then(|encoded| engine.decode(encoded).ok())
708        .unwrap_or_else(|| secret.as_bytes().to_vec());
709    let mut mac =
710        hmac::Hmac::<sha2::Sha256>::new_from_slice(&key).expect("HMAC accepts a key of any length");
711    mac.update(format!("{id}.{timestamp}.{body}").as_bytes());
712    format!("v1,{}", engine.encode(mac.finalize().into_bytes()))
713}
714
715/// Asynchronous background job that delivers a webhook payload (legacy fallback).
716#[allow(
717    clippy::redundant_closure_for_method_calls,
718    clippy::too_many_lines,
719    clippy::must_use_candidate
720)]
721pub fn deliver_webhook_job(
722    state: AppState,
723    payload: serde_json::Value,
724) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send + 'static>> {
725    Box::pin(async move {
726        let is_replay = payload
727            .get("replay")
728            .and_then(serde_json::Value::as_bool)
729            .unwrap_or(false);
730        let manager = state.extension::<WebhookOutboundManager>().ok_or_else(|| {
731            AutumnError::internal_server_error_msg("WebhookOutboundManager not found in extensions")
732        })?;
733
734        // Support both self-contained payload structure and legacy log_id lookup (for replays)
735        let (sub, mut log) = if let Some(sub_val) = payload.get("subscription") {
736            let _payload_sub: WebhookSubscription = serde_json::from_value(sub_val.clone())
737                .map_err(|e| {
738                    AutumnError::bad_request_msg(format!("failed to parse subscription: {e}"))
739                })?;
740            let mut log: WebhookDeliveryLog = serde_json::from_value(
741                payload
742                    .get("log")
743                    .cloned()
744                    .ok_or_else(|| AutumnError::bad_request_msg("missing log in job payload"))?,
745            )
746            .map_err(|e| AutumnError::bad_request_msg(format!("failed to parse log: {e}")))?;
747
748            if !begin_attempt(&manager, &mut log, is_replay).await? {
749                return Ok(());
750            }
751
752            let sub = load_current_subscription(&manager, &log).await?;
753            (sub, log)
754        } else {
755            let log_id = payload
756                .get("log_id")
757                .and_then(|v| v.as_str())
758                .ok_or_else(|| AutumnError::bad_request_msg("missing log_id in job payload"))?;
759
760            tracing::debug!(log_id = %log_id, "deliver_webhook_job: starting webhook delivery via log lookup");
761
762            let log_opt = manager.store().get_delivery_log(log_id).await?;
763            let mut log = log_opt.ok_or_else(|| {
764                AutumnError::not_found_msg(format!("delivery log {log_id} not found"))
765            })?;
766
767            // A second job for a delivered log (an outbox re-send can enqueue
768            // one) must not send again.
769            if !is_replay
770                && log
771                    .response_status
772                    .is_some_and(|status| (200..300).contains(&status))
773            {
774                tracing::debug!(log_id = %log_id, "webhook delivery already succeeded; skipping");
775                return Ok(());
776            }
777
778            if !begin_attempt(&manager, &mut log, is_replay).await? {
779                return Ok(());
780            }
781
782            // Load latest subscription state to respect emergency rotations/disable
783            let sub = load_current_subscription(&manager, &log).await?;
784            (sub, log)
785        };
786
787        if sub.status == WebhookSubscriptionStatus::Disabled {
788            tracing::info!(subscription_id = %sub.id, "Webhook subscription is disabled; skipping delivery");
789            log.last_error = Some("Subscription is disabled".to_owned());
790            log.timestamp = crate::time::ambient_now();
791            if is_replay {
792                log.is_dlq = true;
793            }
794            manager.store().log_delivery(log).await?;
795            return Ok(());
796        }
797
798        if sub.status == WebhookSubscriptionStatus::Failed && !is_replay {
799            tracing::info!(subscription_id = %sub.id, "Webhook subscription has failed; skipping delivery");
800            log.last_error = Some("Subscription has failed due to consecutive errors".to_owned());
801            log.timestamp = crate::time::ambient_now();
802            manager.store().log_delivery(log).await?;
803            return Ok(());
804        }
805        if sub.status == WebhookSubscriptionStatus::Failed {
806            tracing::info!(subscription_id = %sub.id, "Replaying webhook delivery for failed subscription");
807        }
808
809        // Stripe-style payload signing: t=<timestamp>,v1=<signature>
810        // The receiver checks `t=` against its own real clock, so sign with
811        // the ambient clock: real time outside a `Sim` (issue #2967).
812        let timestamp = crate::time::ambient_now().timestamp();
813        let signing_payload = format!("{timestamp}.{}", log.payload);
814        let signature = crate::security::config::hmac_sha256_hex(
815            sub.secret.as_bytes(),
816            signing_payload.as_bytes(),
817        );
818        let signature_header = format!("t={timestamp},v1={signature}");
819
820        // Standard Webhooks headers (issue #3062). `webhook-id` is the log
821        // id. Job retries use the same log, so each attempt sends the same
822        // id. `webhook-signature` signs the id too, so a replay with a new id
823        // fails the check.
824        let mut request_headers = HashMap::new();
825        request_headers.insert("Content-Type".to_owned(), "application/json".to_owned());
826        request_headers.insert("Autumn-Signature".to_owned(), signature_header);
827        request_headers.insert("webhook-id".to_owned(), log.id.clone());
828        request_headers.insert("webhook-timestamp".to_owned(), timestamp.to_string());
829        request_headers.insert(
830            "webhook-signature".to_owned(),
831            standard_webhooks_signature(&sub.secret, &log.id, timestamp, &log.payload),
832        );
833
834        let start = crate::time::ambient_monotonic();
835        // `target_url` is a subscriber-chosen destination, not one the app
836        // itself picked — exactly the case `ssrf_safe()` exists for. Without
837        // it this POST carries none of the private/link-local/loopback/cloud-
838        // metadata deny-list `Client::get_ssrf_safe` documents. `ssrf_safe()`
839        // alone would route through the custom send path, which (like the
840        // mock path) bypasses `send_recorded`'s circuit breaker by default —
841        // right for a one-off fetch of an arbitrary URL, wrong for repeated
842        // deliveries to the same subscriber host. `breaker_scoped()` keeps
843        // the fail-fast-on-a-down-receiver behavior every other outbound call
844        // gets, correctly ordered with the mock bypass, capsule replay, and
845        // capsule *recording* of the attempt — all handled inside
846        // `send_recorded` itself, not layered on here. See
847        // docs/security/2026-09-03-webhook-ssrf/README.md.
848        let mut req = manager
849            .client
850            .named(&sub.target_url)
851            .post(&sub.target_url)
852            .ssrf_safe()
853            .breaker_scoped();
854        // Send exactly the headers the log records.
855        let mut header_names: Vec<&String> = request_headers.keys().collect();
856        header_names.sort();
857        for name in header_names {
858            req = req.header(name, &request_headers[name]);
859        }
860        let req = req.text_body(log.payload.clone());
861
862        let response = req.send().await;
863        let elapsed = u64::try_from(
864            crate::time::ambient_monotonic()
865                .saturating_duration_since(start)
866                .as_millis(),
867        )
868        .unwrap_or(u64::MAX);
869
870        tracing::debug!(
871            log_id = %log.id,
872            status = ?response.as_ref().map(|r| r.status()),
873            "deliver_webhook_job: webhook HTTP request finished"
874        );
875
876        log.elapsed_ms = elapsed;
877        log.timestamp = crate::time::ambient_now();
878        log.request_headers = request_headers;
879
880        match response {
881            Ok(res) => {
882                let status = res.status();
883                log.response_status = Some(status.as_u16());
884                let is_success = res.is_success();
885                let body_str = cap_logged_response_body(res.text());
886                log.response_body = Some(body_str);
887
888                if is_success {
889                    log.last_error = None;
890                    manager.store().log_delivery(log).await?;
891                    reset_subscription_after_success(&manager, &sub).await;
892                    Ok(())
893                } else {
894                    let status_err = format!("server returned status: {status}");
895                    log.last_error = Some(status_err.clone());
896                    if attempts_remain(&manager, &log) {
897                        manager.store().log_delivery(log.clone()).await?;
898                    }
899                    handle_delivery_failure(&manager, &sub, log, status_err).await
900                }
901            }
902            Err(e) => {
903                let error_str = e.to_string();
904                log.last_error = Some(error_str.clone());
905                if attempts_remain(&manager, &log) {
906                    manager.store().log_delivery(log.clone()).await?;
907                }
908                handle_delivery_failure(&manager, &sub, log, error_str).await
909            }
910        }
911    })
912}
913
914/// `true` when this delivery has attempts left. The limit is the smaller of
915/// the log's own limit and the manager's. A log written before a deploy that
916/// lowered the limit then still moves to the DLQ before the job engine runs
917/// out of job attempts (2 per delivery attempt).
918fn attempts_remain(manager: &WebhookOutboundManager, log: &WebhookDeliveryLog) -> bool {
919    log.attempt < log.max_attempts.min(manager.max_attempts)
920}
921
922/// Prepares the log for this run of the delivery job. Returns `false` when
923/// the delivery must not be sent.
924///
925/// A log that was already attempted (it has a status or an error) is a retry
926/// from the job engine. The retry gets the next attempt number, and the
927/// pre-send log is written. But first the limit is checked: a deploy can lower
928/// the limit while a retry waits, and that retry must go to the DLQ without a
929/// further request. A manual DLQ replay is not limited.
930async fn begin_attempt(
931    manager: &WebhookOutboundManager,
932    log: &mut WebhookDeliveryLog,
933    is_replay: bool,
934) -> AutumnResult<bool> {
935    // A replay takes the log out of the DLQ. A new failure that uses the
936    // last attempt puts it back.
937    if is_replay {
938        log.is_dlq = false;
939    }
940    if log.response_status.is_none() && log.last_error.is_none() {
941        return Ok(true);
942    }
943    if !is_replay && !attempts_remain(manager, log) {
944        log.is_dlq = true;
945        manager.store().log_delivery(log.clone()).await?;
946        tracing::warn!(
947            subscription_id = %log.subscription_id,
948            attempt = log.attempt,
949            "Webhook delivery reached its lowered attempt limit; sent to DLQ"
950        );
951        return Ok(false);
952    }
953    log.attempt = log.attempt.saturating_add(1);
954    log.response_status = None;
955    log.response_body = None;
956    log.last_error = None;
957    manager.store().log_delivery(log.clone()).await?;
958    Ok(true)
959}
960
961async fn load_current_subscription(
962    manager: &WebhookOutboundManager,
963    log: &WebhookDeliveryLog,
964) -> AutumnResult<WebhookSubscription> {
965    manager
966        .store()
967        .get_subscription(&log.subscription_id)
968        .await?
969        .ok_or_else(|| {
970            AutumnError::not_found_msg(format!("subscription {} not found", log.subscription_id))
971        })
972}
973
974fn cap_logged_response_body(mut body: String) -> String {
975    if body.len() <= MAX_LOGGED_RESPONSE_BODY_BYTES {
976        return body;
977    }
978
979    let body_budget =
980        MAX_LOGGED_RESPONSE_BODY_BYTES.saturating_sub(TRUNCATED_RESPONSE_BODY_SUFFIX.len());
981    let mut cutoff = body_budget.min(body.len());
982    while cutoff > 0 && !body.is_char_boundary(cutoff) {
983        cutoff -= 1;
984    }
985    body.truncate(cutoff);
986    body.push_str(TRUNCATED_RESPONSE_BODY_SUFFIX);
987    body
988}
989
990async fn reset_subscription_after_success(
991    manager: &WebhookOutboundManager,
992    sub: &WebhookSubscription,
993) {
994    if let Err(e) = manager
995        .store()
996        .reactivate_failed_subscription(&sub.id)
997        .await
998    {
999        tracing::warn!(
1000            subscription_id = %sub.id,
1001            "Webhook delivery succeeded but subscription failure state could not be reset: {}",
1002            e
1003        );
1004    }
1005}
1006
1007async fn handle_delivery_failure(
1008    manager: &WebhookOutboundManager,
1009    sub: &WebhookSubscription,
1010    mut log: WebhookDeliveryLog,
1011    error_msg: String,
1012) -> AutumnResult<()> {
1013    if attempts_remain(manager, &log) {
1014        // Return an error to signal the background job runner to retry this job
1015        Err(AutumnError::internal_server_error_msg(format!(
1016            "delivery attempt {} failed, scheduled retry: {error_msg}",
1017            log.attempt
1018        )))
1019    } else {
1020        log.is_dlq = true;
1021        manager.store().log_delivery(log).await?;
1022        // Return Ok(()) to mark the permanently failed job as complete and send to DLQ
1023        tracing::warn!(subscription_id = %sub.id, "Webhook delivery failed permanently; sent to DLQ: {}", error_msg);
1024        Ok(())
1025    }
1026}
1027
1028/// `AppBuilder` plugin for outbound signed webhook delivery infrastructure.
1029pub struct OutboundWebhookPlugin {
1030    store: StoreSource,
1031    initial_backoff_ms: u64,
1032    max_attempts: u32,
1033}
1034
1035/// Where the plugin gets its store.
1036enum StoreSource {
1037    Given(Arc<dyn OutboundWebhookHandler>),
1038    /// A [`SqlOutboundWebhookStore`] on the app pool.
1039    #[cfg(feature = "db")]
1040    AppDatabase,
1041}
1042
1043impl OutboundWebhookPlugin {
1044    /// Create a new outbound webhook plugin using the specified store.
1045    #[must_use]
1046    pub fn new(store: Arc<dyn OutboundWebhookHandler>) -> Self {
1047        Self {
1048            store: StoreSource::Given(store),
1049            initial_backoff_ms: 1000,
1050            max_attempts: DEFAULT_WEBHOOK_MAX_ATTEMPTS,
1051        }
1052    }
1053
1054    /// A plugin with a durable [`SqlOutboundWebhookStore`] on the app
1055    /// database. At startup it creates the tables if they do not exist.
1056    /// `TestApp` runs no startup hook: call
1057    /// [`SqlOutboundWebhookStore::ensure_schema`] in the test.
1058    #[cfg(feature = "db")]
1059    #[must_use]
1060    pub const fn sql() -> Self {
1061        Self {
1062            store: StoreSource::AppDatabase,
1063            initial_backoff_ms: 1000,
1064            max_attempts: DEFAULT_WEBHOOK_MAX_ATTEMPTS,
1065        }
1066    }
1067
1068    /// Override the initial backoff retry delay.
1069    #[must_use]
1070    pub const fn with_initial_backoff_ms(mut self, ms: u64) -> Self {
1071        self.initial_backoff_ms = ms;
1072        self
1073    }
1074
1075    /// Set the number of delivery attempts per webhook. Default: 5.
1076    /// A value of 0 is read as 1. The last failed attempt moves the delivery
1077    /// to the DLQ.
1078    #[must_use]
1079    pub const fn with_max_attempts(mut self, max_attempts: u32) -> Self {
1080        self.max_attempts = if max_attempts == 0 { 1 } else { max_attempts };
1081        self
1082    }
1083}
1084
1085impl crate::plugin::Plugin for OutboundWebhookPlugin {
1086    fn build(self, app: crate::app::AppBuilder) -> crate::app::AppBuilder {
1087        let initial_backoff_ms = self.initial_backoff_ms;
1088        let max_attempts = self.max_attempts;
1089        let app = match self.store {
1090            StoreSource::Given(store) => app.state_initializer(move |state| {
1091                install_outbound_webhook_manager(
1092                    state,
1093                    store.clone(),
1094                    initial_backoff_ms,
1095                    max_attempts,
1096                );
1097            }),
1098            #[cfg(feature = "db")]
1099            StoreSource::AppDatabase => app
1100                .state_initializer(move |state| {
1101                    if let Some(pool) = state.pool() {
1102                        install_outbound_webhook_manager(
1103                            state,
1104                            Arc::new(SqlOutboundWebhookStore::new(pool.clone())),
1105                            initial_backoff_ms,
1106                            max_attempts,
1107                        );
1108                    } else {
1109                        tracing::error!(
1110                            "OutboundWebhookPlugin::sql needs a database; webhooks are off"
1111                        );
1112                    }
1113                })
1114                .on_startup(|state| async move {
1115                    match state.pool() {
1116                        Some(pool) => {
1117                            SqlOutboundWebhookStore::new(pool.clone())
1118                                .ensure_schema()
1119                                .await
1120                        }
1121                        None => Ok(()),
1122                    }
1123                }),
1124        };
1125        app.jobs(vec![crate::job::JobInfo {
1126            name: "autumn_webhook_delivery".to_string(),
1127            // The job engine runs the retries. Two job attempts per delivery
1128            // attempt leave room for store errors, which use a job attempt
1129            // but not a delivery attempt.
1130            max_attempts: max_attempts.saturating_mul(2),
1131            initial_backoff_ms,
1132            queue: "default".to_string(),
1133            uniqueness: None,
1134            concurrency: None,
1135            version: 1,
1136            handler: deliver_webhook_job,
1137            timeout: None,
1138        }])
1139    }
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144    use super::*;
1145    use crate::http_client::{HttpMockRegistryExt, MockRegistry, MockSetupBuilder};
1146    use std::sync::Arc;
1147    use std::sync::atomic::{AtomicUsize, Ordering};
1148
1149    fn mock_builder(registry: Arc<MockRegistry>, alias: &str) -> MockSetupBuilder {
1150        MockSetupBuilder {
1151            registry,
1152            alias: alias.to_owned(),
1153            method: None,
1154            path: None,
1155        }
1156    }
1157
1158    fn sample_subscription(
1159        id: &str,
1160        target_url: &str,
1161        status: WebhookSubscriptionStatus,
1162    ) -> WebhookSubscription {
1163        WebhookSubscription {
1164            id: id.to_owned(),
1165            target_url: target_url.to_owned(),
1166            event_topics: vec!["orders.created".to_owned()],
1167            secret: "my_webhook_signing_secret_32_bytes!!".to_owned(),
1168            status,
1169            consecutive_failures: if status == WebhookSubscriptionStatus::Failed {
1170                50
1171            } else {
1172                0
1173            },
1174        }
1175    }
1176
1177    fn sample_log(id: &str, subscription_id: &str) -> WebhookDeliveryLog {
1178        WebhookDeliveryLog {
1179            id: id.to_owned(),
1180            subscription_id: subscription_id.to_owned(),
1181            topic: "orders.created".to_owned(),
1182            payload: serde_json::json!({ "order_id": "ord_123" }).to_string(),
1183            request_headers: HashMap::new(),
1184            response_status: None,
1185            response_body: None,
1186            elapsed_ms: 0,
1187            attempt: 1,
1188            max_attempts: 5,
1189            is_dlq: false,
1190            last_error: None,
1191            timestamp: Utc::now(),
1192        }
1193    }
1194
1195    #[test]
1196    fn outbound_webhook_plugin_installs_manager_without_startup_hook() {
1197        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1198        let builder = crate::app().plugin(OutboundWebhookPlugin::new(store));
1199
1200        assert!(
1201            builder.startup_hooks.is_empty(),
1202            "webhook manager must be installed before job workers start, not from a startup hook"
1203        );
1204        assert_eq!(builder.state_initializers.len(), 1);
1205    }
1206
1207    /// Issue #3054: the number of delivery attempts is configurable.
1208    #[tokio::test]
1209    async fn max_attempts_reaches_the_delivery_log_and_the_job() {
1210        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
1211        crate::job::clear_global_job_client();
1212        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1213        let mut builder =
1214            crate::app().plugin(OutboundWebhookPlugin::new(store.clone()).with_max_attempts(3));
1215        assert_eq!(
1216            builder.jobs[0].max_attempts, 6,
1217            "two job attempts per delivery"
1218        );
1219
1220        let state = AppState::for_test();
1221        let initializer = builder.state_initializers.remove(0);
1222        initializer(&state);
1223        let manager = state
1224            .extension::<WebhookOutboundManager>()
1225            .expect("manager installed");
1226        assert_eq!(manager.max_attempts(), 3);
1227
1228        store
1229            .create_subscription(sample_subscription(
1230                "sub_n",
1231                "http://mock-receiver/n",
1232                WebhookSubscriptionStatus::Active,
1233            ))
1234            .await
1235            .unwrap();
1236        // No job client: the dispatch fails, but the log is written first.
1237        let _ = manager
1238            .dispatch(&state, "orders.created", &serde_json::json!({}))
1239            .await;
1240        let logs = store.get_delivery_logs().await.unwrap();
1241        assert_eq!(logs.len(), 1);
1242        assert_eq!(logs[0].max_attempts, 3);
1243    }
1244
1245    #[test]
1246    fn zero_max_attempts_reads_as_one() {
1247        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1248        assert_eq!(
1249            WebhookOutboundManager::new(store)
1250                .with_max_attempts(0)
1251                .max_attempts(),
1252            1
1253        );
1254    }
1255
1256    #[tokio::test]
1257    async fn replay_job_sends_failed_subscription_instead_of_skipping() {
1258        let state = AppState::for_test();
1259        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1260        let registry = Arc::new(MockRegistry::new());
1261        let mock = mock_builder(registry.clone(), "http://mock-receiver/webhooks/replay")
1262            .post("/webhooks/replay")
1263            .respond_with(200, serde_json::json!({ "received": true }));
1264        state.insert_extension(HttpMockRegistryExt(registry));
1265        install_outbound_webhook_manager(&state, store.clone(), 1, DEFAULT_WEBHOOK_MAX_ATTEMPTS);
1266
1267        let sub = sample_subscription(
1268            "sub_failed",
1269            "http://mock-receiver/webhooks/replay",
1270            WebhookSubscriptionStatus::Failed,
1271        );
1272        store.create_subscription(sub).await.unwrap();
1273        store
1274            .replace_delivery_log(sample_log("log_replay", "sub_failed"))
1275            .await
1276            .unwrap();
1277
1278        deliver_webhook_job(
1279            state,
1280            serde_json::json!({
1281                "log_id": "log_replay",
1282                "replay": true,
1283            }),
1284        )
1285        .await
1286        .unwrap();
1287
1288        mock.expect_called(1);
1289        let log = store
1290            .get_delivery_log("log_replay")
1291            .await
1292            .unwrap()
1293            .expect("log should remain stored");
1294        assert_eq!(log.response_status, Some(200));
1295        assert!(!log.is_dlq);
1296        assert!(log.last_error.is_none());
1297
1298        let updated_sub = store
1299            .get_subscription("sub_failed")
1300            .await
1301            .unwrap()
1302            .expect("subscription should remain stored");
1303        assert_eq!(updated_sub.status, WebhookSubscriptionStatus::Active);
1304        assert_eq!(updated_sub.consecutive_failures, 0);
1305    }
1306
1307    /// Issue #3062: each retry sends the same `webhook-id`, so a receiver can
1308    /// drop a duplicate.
1309    /// A 2xx log is final. A late failure of a duplicate job does not
1310    /// overwrite it or count as a failure.
1311    #[tokio::test]
1312    async fn a_success_log_is_final() {
1313        let store = InMemoryOutboundWebhookHandler::new();
1314        store
1315            .create_subscription(sample_subscription(
1316                "sub",
1317                "http://receiver/hooks",
1318                WebhookSubscriptionStatus::Active,
1319            ))
1320            .await
1321            .unwrap();
1322        let mut ok = sample_log("log", "sub");
1323        ok.response_status = Some(200);
1324        store.log_delivery(ok).await.unwrap();
1325
1326        let mut late = sample_log("log", "sub");
1327        late.response_status = Some(500);
1328        late.last_error = Some("500".to_owned());
1329        store.log_delivery(late).await.unwrap();
1330        let mut pending = sample_log("log", "sub");
1331        pending.attempt = 2;
1332        store.log_delivery(pending).await.unwrap();
1333
1334        let log = store.get_delivery_log("log").await.unwrap().unwrap();
1335        assert_eq!(log.response_status, Some(200));
1336        assert_eq!(log.attempt, 1);
1337        let sub = store.get_subscription("sub").await.unwrap().unwrap();
1338        assert_eq!(sub.consecutive_failures, 0);
1339    }
1340
1341    /// Two duplicate jobs that fail one attempt count one failure. A later
1342    /// DLQ move of that attempt is still stored.
1343    #[tokio::test]
1344    async fn a_repeated_failure_of_one_attempt_counts_once() {
1345        let store = InMemoryOutboundWebhookHandler::new();
1346        store
1347            .create_subscription(sample_subscription(
1348                "sub",
1349                "http://receiver/hooks",
1350                WebhookSubscriptionStatus::Active,
1351            ))
1352            .await
1353            .unwrap();
1354        let mut failed = sample_log("log", "sub");
1355        failed.response_status = Some(500);
1356        failed.last_error = Some("500".to_owned());
1357        store.log_delivery(failed.clone()).await.unwrap();
1358        store.log_delivery(failed.clone()).await.unwrap();
1359        let sub = store.get_subscription("sub").await.unwrap().unwrap();
1360        assert_eq!(sub.consecutive_failures, 1);
1361
1362        failed.is_dlq = true;
1363        store.log_delivery(failed).await.unwrap();
1364        let log = store.get_delivery_log("log").await.unwrap().unwrap();
1365        assert!(log.is_dlq, "the DLQ move is stored");
1366        let sub = store.get_subscription("sub").await.unwrap().unwrap();
1367        assert_eq!(
1368            sub.consecutive_failures, 1,
1369            "a DLQ move is not a new failure"
1370        );
1371
1372        // A 2xx of the same attempt replaces a failure.
1373        let mut lost = sample_log("other", "sub");
1374        lost.response_status = Some(500);
1375        lost.last_error = Some("500".to_owned());
1376        store.log_delivery(lost.clone()).await.unwrap();
1377        lost.response_status = Some(200);
1378        lost.last_error = None;
1379        store.log_delivery(lost).await.unwrap();
1380        let log = store.get_delivery_log("other").await.unwrap().unwrap();
1381        assert_eq!(log.response_status, Some(200));
1382        let sub = store.get_subscription("sub").await.unwrap().unwrap();
1383        assert_eq!(sub.consecutive_failures, 0);
1384    }
1385
1386    /// A failure of an older attempt is stale and ignored. A replay reset
1387    /// (out of the DLQ, back to attempt 1) still applies.
1388    #[tokio::test]
1389    async fn a_stale_attempt_is_ignored() {
1390        let store = InMemoryOutboundWebhookHandler::new();
1391        store
1392            .create_subscription(sample_subscription(
1393                "sub",
1394                "http://receiver/hooks",
1395                WebhookSubscriptionStatus::Active,
1396            ))
1397            .await
1398            .unwrap();
1399        let mut newer = sample_log("log", "sub");
1400        newer.attempt = 2;
1401        store.log_delivery(newer).await.unwrap();
1402        let mut stale = sample_log("log", "sub");
1403        stale.response_status = Some(500);
1404        stale.last_error = Some("500".to_owned());
1405        store.log_delivery(stale.clone()).await.unwrap();
1406        let log = store.get_delivery_log("log").await.unwrap().unwrap();
1407        assert_eq!((log.attempt, log.response_status), (2, None));
1408        let sub = store.get_subscription("sub").await.unwrap().unwrap();
1409        assert_eq!(sub.consecutive_failures, 0);
1410
1411        let mut dead = stale.clone();
1412        dead.id = "dead".to_owned();
1413        dead.attempt = 3;
1414        dead.is_dlq = true;
1415        store.log_delivery(dead).await.unwrap();
1416        let mut reset = sample_log("dead", "sub");
1417        reset.attempt = 1;
1418        store.log_delivery(reset).await.unwrap();
1419        let log = store.get_delivery_log("dead").await.unwrap().unwrap();
1420        assert_eq!(
1421            (log.attempt, log.is_dlq),
1422            (1, false),
1423            "a replay reset applies"
1424        );
1425    }
1426
1427    #[tokio::test]
1428    async fn webhook_retries_carry_identical_webhook_id() {
1429        let state = AppState::for_test();
1430        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1431        let registry = Arc::new(MockRegistry::new());
1432        let mock = mock_builder(registry.clone(), "http://mock-receiver/webhooks/flaky")
1433            .post("/webhooks/flaky")
1434            .respond_with(500, serde_json::json!({ "error": "down" }));
1435        state.insert_extension(HttpMockRegistryExt(registry));
1436        install_outbound_webhook_manager(&state, store.clone(), 1, DEFAULT_WEBHOOK_MAX_ATTEMPTS);
1437
1438        let sub = sample_subscription(
1439            "sub_flaky",
1440            "http://mock-receiver/webhooks/flaky",
1441            WebhookSubscriptionStatus::Active,
1442        );
1443        store.create_subscription(sub).await.unwrap();
1444        store
1445            .replace_delivery_log(sample_log("log_flaky", "sub_flaky"))
1446            .await
1447            .unwrap();
1448
1449        let mut ids = Vec::new();
1450        for attempt in 1..=3_u32 {
1451            let result =
1452                deliver_webhook_job(state.clone(), serde_json::json!({ "log_id": "log_flaky" }))
1453                    .await;
1454            assert!(result.is_err(), "a 500 asks the job runtime to retry");
1455            let log = store.get_delivery_log("log_flaky").await.unwrap().unwrap();
1456            assert_eq!(log.attempt, attempt);
1457            let id = log
1458                .request_headers
1459                .get("webhook-id")
1460                .cloned()
1461                .expect("every attempt sends webhook-id");
1462            let timestamp = log
1463                .request_headers
1464                .get("webhook-timestamp")
1465                .expect("every attempt sends webhook-timestamp");
1466            let signature = &log.request_headers["Autumn-Signature"];
1467            assert!(signature.starts_with(&format!("t={timestamp},")));
1468            ids.push(id);
1469        }
1470
1471        mock.expect_called(3);
1472        assert!(ids.iter().all(|id| id == "log_flaky"), "ids: {ids:?}");
1473    }
1474
1475    /// Issue #3062: a relay re-send of one outbox message makes no second
1476    /// delivery once an attempt started, and enqueues again before that.
1477    #[cfg(feature = "db")]
1478    #[tokio::test]
1479    async fn outbox_resend_reuses_the_delivery_id() {
1480        let state = AppState::for_test();
1481        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1482        install_outbound_webhook_manager(&state, store.clone(), 1, DEFAULT_WEBHOOK_MAX_ATTEMPTS);
1483        let delegated = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1484        let delegate: WebhookDelegate = {
1485            let delegated = delegated.clone();
1486            Arc::new(move |_, _, log| {
1487                delegated.lock().unwrap().push(log.id);
1488                Box::pin(async { Ok(()) })
1489            })
1490        };
1491        state.insert_extension(WebhookDelegateExt(delegate));
1492        store
1493            .create_subscription(sample_subscription(
1494                "sub_outbox",
1495                "http://mock-receiver/hook",
1496                WebhookSubscriptionStatus::Active,
1497            ))
1498            .await
1499            .unwrap();
1500        let manager = state.extension::<WebhookOutboundManager>().unwrap();
1501        let payload = serde_json::json!({ "order_id": "ord_1" });
1502
1503        for _ in 0..2 {
1504            manager
1505                .dispatch_for_message(&state, "msg-1", "orders.created", &payload)
1506                .await
1507                .unwrap();
1508        }
1509        let ids = delegated.lock().unwrap().clone();
1510        assert_eq!(ids.len(), 2, "no attempt yet: enqueue again");
1511        assert_eq!(
1512            ids[0], ids[1],
1513            "one delivery id per message and subscription"
1514        );
1515        assert_eq!(ids[0], delivery_id_for_message("msg-1", "sub_outbox"));
1516        assert_eq!(store.get_delivery_logs().await.unwrap().len(), 1);
1517
1518        let mut log = store.get_delivery_log(&ids[0]).await.unwrap().unwrap();
1519        log.response_status = Some(200);
1520        store.replace_delivery_log(log).await.unwrap();
1521        manager
1522            .dispatch_for_message(&state, "msg-1", "orders.created", &payload)
1523            .await
1524            .unwrap();
1525        assert_eq!(
1526            delegated.lock().unwrap().len(),
1527            2,
1528            "an attempt started: skip"
1529        );
1530
1531        manager
1532            .dispatch_for_message(&state, "msg-2", "orders.created", &payload)
1533            .await
1534            .unwrap();
1535        assert_ne!(
1536            delegated.lock().unwrap()[2],
1537            ids[0],
1538            "a new message gets a new id"
1539        );
1540    }
1541
1542    /// The test vector of the Standard Webhooks spec.
1543    #[test]
1544    fn webhook_signature_matches_the_standard_webhooks_vector() {
1545        assert_eq!(
1546            standard_webhooks_signature(
1547                "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
1548                "msg_p5jXN8AQM9LWM0D4loKWxJek",
1549                1_614_265_330,
1550                r#"{"test": 2432232314}"#,
1551            ),
1552            "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="
1553        );
1554        // A secret without the prefix signs with its raw bytes.
1555        assert_ne!(
1556            standard_webhooks_signature("raw-secret", "id", 1, "{}"),
1557            standard_webhooks_signature("other-secret", "id", 1, "{}")
1558        );
1559    }
1560
1561    /// A second job for a delivered log (an outbox re-send) sends nothing.
1562    #[tokio::test]
1563    async fn duplicate_job_after_success_does_not_send_again() {
1564        let state = AppState::for_test();
1565        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1566        let registry = Arc::new(MockRegistry::new());
1567        let mock = mock_builder(registry.clone(), "http://mock-receiver/webhooks/once")
1568            .post("/webhooks/once")
1569            .respond_with(200, serde_json::json!({ "ok": true }));
1570        state.insert_extension(HttpMockRegistryExt(registry));
1571        install_outbound_webhook_manager(&state, store.clone(), 1, DEFAULT_WEBHOOK_MAX_ATTEMPTS);
1572        store
1573            .create_subscription(sample_subscription(
1574                "sub_once",
1575                "http://mock-receiver/webhooks/once",
1576                WebhookSubscriptionStatus::Active,
1577            ))
1578            .await
1579            .unwrap();
1580        store
1581            .replace_delivery_log(sample_log("log_once", "sub_once"))
1582            .await
1583            .unwrap();
1584
1585        for _ in 0..2 {
1586            deliver_webhook_job(state.clone(), serde_json::json!({ "log_id": "log_once" }))
1587                .await
1588                .unwrap();
1589        }
1590        mock.expect_called(1);
1591        let log = store.get_delivery_log("log_once").await.unwrap().unwrap();
1592        assert_eq!(log.attempt, 1);
1593        assert!(log.request_headers["webhook-signature"].starts_with("v1,"));
1594    }
1595
1596    /// From the outbox, a failed enqueue keeps the log pending, so the next
1597    /// relay send enqueues it. Outside the outbox, it goes to the DLQ.
1598    #[cfg(feature = "db")]
1599    #[tokio::test]
1600    async fn outbox_enqueue_failure_stays_retryable() {
1601        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
1602        crate::job::clear_global_job_client();
1603        let state = AppState::for_test();
1604        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1605        install_outbound_webhook_manager(&state, store.clone(), 1, DEFAULT_WEBHOOK_MAX_ATTEMPTS);
1606        store
1607            .create_subscription(sample_subscription(
1608                "sub_retry",
1609                "http://mock-receiver/hook",
1610                WebhookSubscriptionStatus::Active,
1611            ))
1612            .await
1613            .unwrap();
1614        let manager = state.extension::<WebhookOutboundManager>().unwrap();
1615        let payload = serde_json::json!({ "order_id": "ord_1" });
1616
1617        // No job client: the enqueue fails.
1618        assert!(
1619            manager
1620                .dispatch_for_message(&state, "msg-r", "orders.created", &payload)
1621                .await
1622                .is_err()
1623        );
1624        let id = delivery_id_for_message("msg-r", "sub_retry");
1625        let log = store.get_delivery_log(&id).await.unwrap().unwrap();
1626        assert!(
1627            !log.is_dlq && log.last_error.is_none(),
1628            "the log stays pending"
1629        );
1630
1631        // The relay sends again; now the enqueue works.
1632        let delegated = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1633        let delegate: WebhookDelegate = {
1634            let delegated = delegated.clone();
1635            Arc::new(move |_, _, log| {
1636                delegated.lock().unwrap().push(log.id);
1637                Box::pin(async { Ok(()) })
1638            })
1639        };
1640        state.insert_extension(WebhookDelegateExt(delegate));
1641        manager
1642            .dispatch_for_message(&state, "msg-r", "orders.created", &payload)
1643            .await
1644            .unwrap();
1645        assert_eq!(*delegated.lock().unwrap(), [id]);
1646    }
1647
1648    #[tokio::test]
1649    async fn replay_job_keeps_disabled_subscription_log_in_dlq() {
1650        let state = AppState::for_test();
1651        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1652        let registry = Arc::new(MockRegistry::new());
1653        let mock = mock_builder(registry.clone(), "http://mock-receiver/webhooks/disabled")
1654            .post("/webhooks/disabled")
1655            .respond_with(200, serde_json::json!({ "received": true }));
1656        state.insert_extension(HttpMockRegistryExt(registry));
1657        install_outbound_webhook_manager(&state, store.clone(), 1, DEFAULT_WEBHOOK_MAX_ATTEMPTS);
1658
1659        let sub = sample_subscription(
1660            "sub_disabled",
1661            "http://mock-receiver/webhooks/disabled",
1662            WebhookSubscriptionStatus::Disabled,
1663        );
1664        store.create_subscription(sub).await.unwrap();
1665        store
1666            .replace_delivery_log(sample_log("log_disabled_replay", "sub_disabled"))
1667            .await
1668            .unwrap();
1669
1670        deliver_webhook_job(
1671            state,
1672            serde_json::json!({
1673                "log_id": "log_disabled_replay",
1674                "replay": true,
1675            }),
1676        )
1677        .await
1678        .unwrap();
1679
1680        mock.expect_called(0);
1681        let log = store
1682            .get_delivery_log("log_disabled_replay")
1683            .await
1684            .unwrap()
1685            .expect("log should remain stored");
1686        assert!(log.is_dlq, "disabled replay must remain visible in DLQ");
1687        assert_eq!(log.last_error.as_deref(), Some("Subscription is disabled"));
1688        assert_eq!(log.response_status, None);
1689    }
1690
1691    /// 🛡 Warden — SSRF via subscriber-chosen `target_url` (2026-09-03).
1692    ///
1693    /// `WebhookSubscription::target_url` is exactly the kind of destination
1694    /// `docs/guide/outbound-webhooks.md` describes as "a consumer's registered
1695    /// endpoint" — supplied by whoever registers the subscription, not chosen
1696    /// by the app. An attacker who can register (or edit) a subscription can
1697    /// point it at an internal service, the app's own DB host, or a cloud
1698    /// metadata endpoint (169.254.169.254); Autumn's own background job then
1699    /// makes the outbound call from inside the app's network. No app code in
1700    /// this path is doing anything the guide warns against — the guide's only
1701    /// stated security mechanism is the outbound HMAC signature, which says
1702    /// nothing about where the request is allowed to go.
1703    ///
1704    /// `127.0.0.1` stands in for that internal destination: it is on the
1705    /// framework's own SSRF deny-list ([`crate::http_client::is_blocked_ip`]),
1706    /// so a real local listener lets the test observe, without touching the
1707    /// network beyond loopback, whether the connection was ever attempted.
1708    #[tokio::test]
1709    async fn deliver_webhook_job_refuses_ssrf_target_url() {
1710        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1711            .await
1712            .expect("bind loopback listener");
1713        let port = listener
1714            .local_addr()
1715            .expect("listener has a local address")
1716            .port();
1717        let target_url = format!("http://127.0.0.1:{port}/hook");
1718
1719        let state = AppState::for_test();
1720        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1721        // Deliberately NOT registering an `HttpMockRegistryExt` — the mock
1722        // path short-circuits before the SSRF check runs (`send_recorded`
1723        // checks `self.mock.is_some()` first), so this test needs the real
1724        // send path to observe whether the connection was actually blocked.
1725        install_outbound_webhook_manager(&state, store.clone(), 1, DEFAULT_WEBHOOK_MAX_ATTEMPTS);
1726
1727        let sub = sample_subscription("sub_ssrf", &target_url, WebhookSubscriptionStatus::Active);
1728        store.create_subscription(sub).await.unwrap();
1729        store
1730            .replace_delivery_log(sample_log("log_ssrf", "sub_ssrf"))
1731            .await
1732            .unwrap();
1733
1734        let accept = tokio::time::timeout(std::time::Duration::from_millis(500), listener.accept());
1735
1736        let job_result =
1737            deliver_webhook_job(state, serde_json::json!({ "log_id": "log_ssrf" })).await;
1738
1739        assert!(
1740            accept.await.is_err(),
1741            "the listener standing in for an internal service must never see a \
1742             connection — a blocked destination has to be rejected before dial"
1743        );
1744        assert!(
1745            job_result.is_err(),
1746            "delivery to a blocked destination must not report success"
1747        );
1748
1749        let log = store
1750            .get_delivery_log("log_ssrf")
1751            .await
1752            .unwrap()
1753            .expect("log should remain stored");
1754        assert!(
1755            log.last_error
1756                .as_deref()
1757                .is_some_and(|e| e.contains("SSRF")),
1758            "delivery log should record the SSRF block, got: {:?}",
1759            log.last_error
1760        );
1761        assert_eq!(log.response_status, None);
1762    }
1763
1764    #[tokio::test]
1765    async fn self_contained_delivery_uses_latest_subscription_state() {
1766        let state = AppState::for_test();
1767        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1768        let registry = Arc::new(MockRegistry::new());
1769        let stale_mock = mock_builder(registry.clone(), "http://mock-receiver/webhooks/stale")
1770            .post("/webhooks/stale")
1771            .respond_with(200, serde_json::json!({ "received": true }));
1772        state.insert_extension(HttpMockRegistryExt(registry));
1773        install_outbound_webhook_manager(&state, store.clone(), 1, DEFAULT_WEBHOOK_MAX_ATTEMPTS);
1774
1775        let stored_sub = sample_subscription(
1776            "sub_refresh",
1777            "http://mock-receiver/webhooks/current-disabled",
1778            WebhookSubscriptionStatus::Disabled,
1779        );
1780        store.create_subscription(stored_sub).await.unwrap();
1781        let stale_sub = sample_subscription(
1782            "sub_refresh",
1783            "http://mock-receiver/webhooks/stale",
1784            WebhookSubscriptionStatus::Active,
1785        );
1786        let log = sample_log("log_refresh", "sub_refresh");
1787
1788        deliver_webhook_job(
1789            state,
1790            serde_json::json!({
1791                "subscription": stale_sub,
1792                "log": log,
1793            }),
1794        )
1795        .await
1796        .unwrap();
1797
1798        stale_mock.expect_called(0);
1799        let stored = store
1800            .get_delivery_log("log_refresh")
1801            .await
1802            .unwrap()
1803            .expect("delivery log should exist");
1804        assert_eq!(stored.response_status, None);
1805        assert_eq!(
1806            stored.last_error.as_deref(),
1807            Some("Subscription is disabled")
1808        );
1809    }
1810
1811    #[tokio::test]
1812    async fn dispatch_marks_log_dlq_when_fallback_enqueue_fails() {
1813        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
1814        crate::job::clear_global_job_client();
1815
1816        let state = AppState::for_test();
1817        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1818        let manager = WebhookOutboundManager::new(store.clone()).with_initial_backoff_ms(1);
1819        let sub = sample_subscription(
1820            "sub_enqueue_missing",
1821            "http://mock-receiver/webhooks/enqueue-missing",
1822            WebhookSubscriptionStatus::Active,
1823        );
1824        store.create_subscription(sub).await.unwrap();
1825
1826        let err = manager
1827            .dispatch(&state, "orders.created", &serde_json::json!({ "id": 42 }))
1828            .await
1829            .expect_err("dispatch should report the missing fallback job runtime");
1830        assert!(
1831            err.to_string().contains("not enqueued"),
1832            "error should describe the enqueue failure: {err}"
1833        );
1834
1835        let logs = store.get_delivery_logs().await.unwrap();
1836        assert_eq!(logs.len(), 1);
1837        let log = &logs[0];
1838        assert!(
1839            log.is_dlq,
1840            "enqueue failure must leave a replayable DLQ record"
1841        );
1842        assert!(
1843            log.last_error
1844                .as_deref()
1845                .is_some_and(|msg| msg.contains("not enqueued")),
1846            "DLQ log should record enqueue failure: {:?}",
1847            log.last_error
1848        );
1849        assert_eq!(log.response_status, None);
1850
1851        let sub = store
1852            .get_subscription("sub_enqueue_missing")
1853            .await
1854            .unwrap()
1855            .expect("subscription should remain stored");
1856        assert_eq!(sub.consecutive_failures, 0);
1857    }
1858
1859    #[tokio::test]
1860    async fn delivery_log_response_body_is_capped() {
1861        let state = AppState::for_test();
1862        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1863        let registry = Arc::new(MockRegistry::new());
1864        let large_body = "x".repeat(MAX_LOGGED_RESPONSE_BODY_BYTES + 1024);
1865        let _mock = mock_builder(
1866            registry.clone(),
1867            "http://mock-receiver/webhooks/large-error",
1868        )
1869        .post("/webhooks/large-error")
1870        .respond_with(500, serde_json::json!({ "error": large_body }));
1871        state.insert_extension(HttpMockRegistryExt(registry));
1872        install_outbound_webhook_manager(&state, store.clone(), 1, DEFAULT_WEBHOOK_MAX_ATTEMPTS);
1873
1874        let sub = sample_subscription(
1875            "sub_large_error",
1876            "http://mock-receiver/webhooks/large-error",
1877            WebhookSubscriptionStatus::Active,
1878        );
1879        store.create_subscription(sub.clone()).await.unwrap();
1880        let mut log = sample_log("log_large_error", "sub_large_error");
1881        log.max_attempts = 1;
1882
1883        deliver_webhook_job(
1884            state,
1885            serde_json::json!({
1886                "subscription": sub,
1887                "log": log,
1888            }),
1889        )
1890        .await
1891        .unwrap();
1892
1893        let stored = store
1894            .get_delivery_log("log_large_error")
1895            .await
1896            .unwrap()
1897            .expect("delivery log should exist");
1898        let body = stored
1899            .response_body
1900            .expect("response body should be logged");
1901        assert!(
1902            body.len() <= MAX_LOGGED_RESPONSE_BODY_BYTES,
1903            "stored response body should be capped, got {} bytes",
1904            body.len()
1905        );
1906        assert!(body.ends_with("[truncated]"));
1907    }
1908
1909    /// A log written with a limit of 10 moves to the DLQ at the manager's
1910    /// lower limit of 2, so the job engine cannot run out first.
1911    #[tokio::test]
1912    async fn a_lowered_limit_moves_an_old_log_to_the_dlq() {
1913        let state = AppState::for_test();
1914        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1915        let registry = Arc::new(MockRegistry::new());
1916        let _mock = mock_builder(registry.clone(), "http://mock-receiver/webhooks/lowered")
1917            .post("/webhooks/lowered")
1918            .respond_with(500, serde_json::json!({}));
1919        state.insert_extension(HttpMockRegistryExt(registry));
1920        install_outbound_webhook_manager(&state, store.clone(), 1, 2);
1921
1922        let sub = sample_subscription(
1923            "sub_lowered",
1924            "http://mock-receiver/webhooks/lowered",
1925            WebhookSubscriptionStatus::Active,
1926        );
1927        store.create_subscription(sub.clone()).await.unwrap();
1928        let mut log = sample_log("log_lowered", "sub_lowered");
1929        log.attempt = 2;
1930        log.max_attempts = 10;
1931
1932        deliver_webhook_job(
1933            state,
1934            serde_json::json!({ "subscription": sub, "log": log }),
1935        )
1936        .await
1937        .expect("the last attempt settles the job");
1938
1939        let stored = store
1940            .get_delivery_log("log_lowered")
1941            .await
1942            .unwrap()
1943            .expect("delivery log should exist");
1944        assert!(stored.is_dlq, "attempt 2 of a limit of 2 goes to the DLQ");
1945    }
1946
1947    /// A retry that waits while a deploy lowers the limit goes to the DLQ
1948    /// without another request. Attempt 2 failed under a limit of 10; the
1949    /// manager's limit is now 2, so attempt 3 must not be sent.
1950    #[tokio::test]
1951    async fn a_lowered_limit_stops_a_waiting_retry_before_it_is_sent() {
1952        let state = AppState::for_test();
1953        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1954        let registry = Arc::new(MockRegistry::new());
1955        let mock = mock_builder(registry.clone(), "http://mock-receiver/webhooks/waiting")
1956            .post("/webhooks/waiting")
1957            .respond_with(500, serde_json::json!({}));
1958        state.insert_extension(HttpMockRegistryExt(registry));
1959        install_outbound_webhook_manager(&state, store.clone(), 1, 2);
1960
1961        let sub = sample_subscription(
1962            "sub_waiting",
1963            "http://mock-receiver/webhooks/waiting",
1964            WebhookSubscriptionStatus::Active,
1965        );
1966        store.create_subscription(sub.clone()).await.unwrap();
1967        let mut log = sample_log("log_waiting", "sub_waiting");
1968        log.attempt = 2;
1969        log.max_attempts = 10;
1970        log.last_error = Some("HTTP 500".to_owned());
1971
1972        deliver_webhook_job(
1973            state,
1974            serde_json::json!({ "subscription": sub, "log": log }),
1975        )
1976        .await
1977        .expect("the exhausted retry settles the job");
1978
1979        assert_eq!(mock.call_count(), 0, "attempt 3 must not be sent");
1980        let stored = store
1981            .get_delivery_log("log_waiting")
1982            .await
1983            .unwrap()
1984            .expect("delivery log should exist");
1985        assert!(stored.is_dlq, "the waiting retry goes to the DLQ");
1986        assert_eq!(stored.attempt, 2, "no attempt 3 is started");
1987        assert_eq!(
1988            stored.last_error.as_deref(),
1989            Some("HTTP 500"),
1990            "the last real error is kept"
1991        );
1992    }
1993
1994    /// A manual DLQ replay is not limited: an exhausted log is sent again.
1995    #[tokio::test]
1996    async fn a_replay_of_an_exhausted_log_is_still_sent() {
1997        let state = AppState::for_test();
1998        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1999        let registry = Arc::new(MockRegistry::new());
2000        let mock = mock_builder(registry.clone(), "http://mock-receiver/webhooks/exhausted")
2001            .post("/webhooks/exhausted")
2002            .respond_with(200, serde_json::json!({}));
2003        state.insert_extension(HttpMockRegistryExt(registry));
2004        install_outbound_webhook_manager(&state, store.clone(), 1, 2);
2005
2006        store
2007            .create_subscription(sample_subscription(
2008                "sub_exhausted",
2009                "http://mock-receiver/webhooks/exhausted",
2010                WebhookSubscriptionStatus::Active,
2011            ))
2012            .await
2013            .unwrap();
2014        let mut log = sample_log("log_exhausted", "sub_exhausted");
2015        log.attempt = 2;
2016        log.max_attempts = 2;
2017        log.last_error = Some("HTTP 500".to_owned());
2018        log.is_dlq = true;
2019        store.replace_delivery_log(log).await.unwrap();
2020
2021        deliver_webhook_job(
2022            state,
2023            serde_json::json!({ "log_id": "log_exhausted", "replay": true }),
2024        )
2025        .await
2026        .expect("the replay is delivered");
2027
2028        assert_eq!(mock.call_count(), 1, "the replay sends the request");
2029        let log = store
2030            .get_delivery_log("log_exhausted")
2031            .await
2032            .unwrap()
2033            .unwrap();
2034        assert_eq!(log.response_status, Some(200));
2035        assert!(!log.is_dlq, "a successful replay leaves the DLQ");
2036        assert!(store.get_dlq_logs().await.unwrap().is_empty());
2037    }
2038
2039    struct CountingReplacementStore {
2040        log_delivery_calls: AtomicUsize,
2041    }
2042
2043    impl CountingReplacementStore {
2044        fn new() -> Self {
2045            Self {
2046                log_delivery_calls: AtomicUsize::new(0),
2047            }
2048        }
2049
2050        fn log_delivery_count(&self) -> usize {
2051            self.log_delivery_calls.load(Ordering::SeqCst)
2052        }
2053    }
2054
2055    impl OutboundWebhookHandler for CountingReplacementStore {
2056        fn get_subscriptions(
2057            &self,
2058            _topic: &str,
2059        ) -> Pin<Box<dyn Future<Output = AutumnResult<Vec<WebhookSubscription>>> + Send>> {
2060            Box::pin(async { Ok(Vec::new()) })
2061        }
2062
2063        fn log_delivery(
2064            &self,
2065            _log: WebhookDeliveryLog,
2066        ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
2067            self.log_delivery_calls.fetch_add(1, Ordering::SeqCst);
2068            Box::pin(async { Ok(()) })
2069        }
2070
2071        fn replace_delivery_log(
2072            &self,
2073            _log: WebhookDeliveryLog,
2074        ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
2075            Box::pin(async { Ok(()) })
2076        }
2077
2078        fn get_subscription(
2079            &self,
2080            _id: &str,
2081        ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookSubscription>>> + Send>>
2082        {
2083            Box::pin(async { Ok(None) })
2084        }
2085
2086        fn get_delivery_log(
2087            &self,
2088            _id: &str,
2089        ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookDeliveryLog>>> + Send>>
2090        {
2091            Box::pin(async { Ok(None) })
2092        }
2093    }
2094
2095    #[tokio::test]
2096    async fn replace_delivery_log_is_not_a_delivery_outcome() {
2097        let store = CountingReplacementStore::new();
2098        let mut log = sample_log("log_replace", "sub_replace");
2099        log.response_status = Some(500);
2100        log.last_error = Some("server returned status: 500 Internal Server Error".to_owned());
2101        log.is_dlq = true;
2102
2103        store.replace_delivery_log(log).await.unwrap();
2104
2105        assert_eq!(
2106            store.log_delivery_count(),
2107            0,
2108            "plain delivery-log replacement must not call log_delivery"
2109        );
2110    }
2111
2112    struct ResetFailingStore {
2113        inner: InMemoryOutboundWebhookHandler,
2114    }
2115
2116    impl ResetFailingStore {
2117        fn new() -> Self {
2118            Self {
2119                inner: InMemoryOutboundWebhookHandler::new(),
2120            }
2121        }
2122
2123        async fn create_subscription(&self, sub: WebhookSubscription) {
2124            self.inner.create_subscription(sub).await.unwrap();
2125        }
2126
2127        async fn delivery_log(&self, id: &str) -> WebhookDeliveryLog {
2128            self.inner
2129                .get_delivery_log(id)
2130                .await
2131                .unwrap()
2132                .expect("delivery log should exist")
2133        }
2134    }
2135
2136    impl OutboundWebhookHandler for ResetFailingStore {
2137        fn get_subscriptions(
2138            &self,
2139            topic: &str,
2140        ) -> Pin<Box<dyn Future<Output = AutumnResult<Vec<WebhookSubscription>>> + Send>> {
2141            <InMemoryOutboundWebhookHandler as OutboundWebhookHandler>::get_subscriptions(
2142                &self.inner,
2143                topic,
2144            )
2145        }
2146
2147        fn log_delivery(
2148            &self,
2149            log: WebhookDeliveryLog,
2150        ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
2151            <InMemoryOutboundWebhookHandler as OutboundWebhookHandler>::log_delivery(
2152                &self.inner,
2153                log,
2154            )
2155        }
2156
2157        fn replace_delivery_log(
2158            &self,
2159            log: WebhookDeliveryLog,
2160        ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
2161            <InMemoryOutboundWebhookHandler as OutboundWebhookHandler>::replace_delivery_log(
2162                &self.inner,
2163                log,
2164            )
2165        }
2166
2167        fn get_subscription(
2168            &self,
2169            id: &str,
2170        ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookSubscription>>> + Send>>
2171        {
2172            <InMemoryOutboundWebhookHandler as OutboundWebhookHandler>::get_subscription(
2173                &self.inner,
2174                id,
2175            )
2176        }
2177
2178        fn get_delivery_log(
2179            &self,
2180            id: &str,
2181        ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookDeliveryLog>>> + Send>>
2182        {
2183            <InMemoryOutboundWebhookHandler as OutboundWebhookHandler>::get_delivery_log(
2184                &self.inner,
2185                id,
2186            )
2187        }
2188
2189        fn reset_subscription_failures(
2190            &self,
2191            _id: &str,
2192        ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
2193            Box::pin(async {
2194                Err(AutumnError::internal_server_error_msg(
2195                    "reset backend unavailable",
2196                ))
2197            })
2198        }
2199    }
2200
2201    #[tokio::test]
2202    async fn successful_delivery_does_not_retry_when_failure_reset_fails() {
2203        let state = AppState::for_test();
2204        let store = Arc::new(ResetFailingStore::new());
2205        let registry = Arc::new(MockRegistry::new());
2206        let mock = mock_builder(registry.clone(), "http://mock-receiver/webhooks/success")
2207            .post("/webhooks/success")
2208            .respond_with(200, serde_json::json!({ "received": true }));
2209        state.insert_extension(HttpMockRegistryExt(registry));
2210        install_outbound_webhook_manager(&state, store.clone(), 1, DEFAULT_WEBHOOK_MAX_ATTEMPTS);
2211
2212        let sub = sample_subscription(
2213            "sub_success",
2214            "http://mock-receiver/webhooks/success",
2215            WebhookSubscriptionStatus::Active,
2216        );
2217        store.create_subscription(sub.clone()).await;
2218        let log = sample_log("log_success", "sub_success");
2219
2220        deliver_webhook_job(
2221            state,
2222            serde_json::json!({
2223                "subscription": sub,
2224                "log": log,
2225            }),
2226        )
2227        .await
2228        .expect("accepted webhook delivery must not be retried because counter reset failed");
2229
2230        mock.expect_called(1);
2231        let persisted = store.delivery_log("log_success").await;
2232        assert_eq!(persisted.response_status, Some(200));
2233        assert!(persisted.last_error.is_none());
2234    }
2235
2236    #[tokio::test]
2237    async fn webhook_manager_uses_http_client_config_base_urls() {
2238        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
2239        crate::job::clear_global_job_client();
2240
2241        let store = Arc::new(InMemoryOutboundWebhookHandler::new());
2242        let plugin = OutboundWebhookPlugin::new(store.clone()).with_initial_backoff_ms(1);
2243        let mut config = crate::config::AutumnConfig::default();
2244        config.http.client.base_urls.insert(
2245            "hook-service".to_owned(),
2246            "http://mock-receiver/base".to_owned(),
2247        );
2248
2249        let mut app_builder = crate::test::TestApp::new().config(config).plugin(plugin);
2250        let mock = app_builder
2251            .http_mock("hook-service")
2252            .post("/base/hook-service")
2253            .respond_with(200, serde_json::json!({ "received": true }));
2254        let app = app_builder.build();
2255        let state = app.state();
2256
2257        let sub = sample_subscription(
2258            "sub_config",
2259            "hook-service",
2260            WebhookSubscriptionStatus::Active,
2261        );
2262        store.create_subscription(sub.clone()).await.unwrap();
2263        let log = sample_log("log_config", "sub_config");
2264
2265        deliver_webhook_job(
2266            state.clone(),
2267            serde_json::json!({
2268                "subscription": sub,
2269                "log": log,
2270            }),
2271        )
2272        .await
2273        .unwrap();
2274
2275        mock.expect_called(1);
2276        crate::job::clear_global_job_client();
2277    }
2278}