1#![allow(
2 clippy::significant_drop_tightening,
3 clippy::missing_panics_doc,
4 clippy::missing_errors_doc
5)]
6#![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#[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 #[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#[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#[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#[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#[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
125pub trait OutboundWebhookHandler: Send + Sync + 'static {
127 fn get_subscriptions(
129 &self,
130 topic: &str,
131 ) -> Pin<Box<dyn Future<Output = AutumnResult<Vec<WebhookSubscription>>> + Send>>;
132
133 fn log_delivery(
141 &self,
142 log: WebhookDeliveryLog,
143 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>>;
144
145 fn replace_delivery_log(
150 &self,
151 log: WebhookDeliveryLog,
152 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>>;
153
154 fn get_subscription(
156 &self,
157 id: &str,
158 ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookSubscription>>> + Send>>;
159
160 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 fn get_delivery_log(
169 &self,
170 id: &str,
171 ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookDeliveryLog>>> + Send>>;
172
173 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 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
193pub 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#[derive(Debug, Default)]
203pub struct InMemoryOutboundWebhookHandler {
204 subscriptions: RwLock<HashMap<String, WebhookSubscription>>,
205 logs: RwLock<HashMap<String, WebhookDeliveryLog>>,
206}
207
208pub type InMemoryOutboundWebhookStore = InMemoryOutboundWebhookHandler;
210
211impl InMemoryOutboundWebhookHandler {
212 #[must_use]
214 pub fn new() -> Self {
215 Self::default()
216 }
217
218 #[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 #[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 #[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 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
398pub 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#[derive(Clone)]
411pub struct WebhookDelegateExt(pub WebhookDelegate);
412
413#[derive(Clone)]
415pub struct WebhookOutboundManager {
416 handler: Arc<dyn OutboundWebhookHandler>,
417 client: Client,
418 initial_backoff_ms: u64,
419 max_attempts: u32,
420}
421
422pub const DEFAULT_WEBHOOK_MAX_ATTEMPTS: u32 = 5;
424
425impl WebhookOutboundManager {
426 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 #[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 #[must_use]
451 pub const fn max_attempts(&self) -> u32 {
452 self.max_attempts
453 }
454
455 #[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 #[must_use]
469 pub fn store(&self) -> &Arc<dyn OutboundWebhookHandler> {
470 &self.handler
471 }
472
473 #[must_use]
475 pub const fn client(&self) -> &Client {
476 &self.client
477 }
478
479 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 #[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 #[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 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 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 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 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 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
668fn 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
696fn 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#[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 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 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 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 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 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 let mut req = manager
849 .client
850 .named(&sub.target_url)
851 .post(&sub.target_url)
852 .ssrf_safe()
853 .breaker_scoped();
854 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
914fn attempts_remain(manager: &WebhookOutboundManager, log: &WebhookDeliveryLog) -> bool {
919 log.attempt < log.max_attempts.min(manager.max_attempts)
920}
921
922async fn begin_attempt(
931 manager: &WebhookOutboundManager,
932 log: &mut WebhookDeliveryLog,
933 is_replay: bool,
934) -> AutumnResult<bool> {
935 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 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 tracing::warn!(subscription_id = %sub.id, "Webhook delivery failed permanently; sent to DLQ: {}", error_msg);
1024 Ok(())
1025 }
1026}
1027
1028pub struct OutboundWebhookPlugin {
1030 store: StoreSource,
1031 initial_backoff_ms: u64,
1032 max_attempts: u32,
1033}
1034
1035enum StoreSource {
1037 Given(Arc<dyn OutboundWebhookHandler>),
1038 #[cfg(feature = "db")]
1040 AppDatabase,
1041}
1042
1043impl OutboundWebhookPlugin {
1044 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 assert_ne!(
1556 standard_webhooks_signature("raw-secret", "id", 1, "{}"),
1557 standard_webhooks_signature("other-secret", "id", 1, "{}")
1558 );
1559 }
1560
1561 #[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 #[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 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 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 #[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 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 #[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 #[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 #[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}