Skip to main content

stateset_embedded/events/
webhook.rs

1//! Webhook delivery system for external event notifications
2
3use chrono::{DateTime, Utc};
4use reqwest::Url;
5use serde::{Deserialize, Serialize};
6use stateset_core::CommerceEvent;
7use std::collections::{HashMap, VecDeque};
8use std::fmt;
9use std::future::Future;
10use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs};
11use std::sync::{Arc, RwLock};
12use tokio::sync::Semaphore;
13use uuid::Uuid;
14
15/// Webhook endpoint configuration
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Webhook {
18    /// Unique identifier
19    pub id: Uuid,
20    /// Display name
21    pub name: String,
22    /// Target URL for POST requests
23    pub url: String,
24    /// Optional secret for HMAC signature
25    pub secret: Option<String>,
26    /// Event types to receive (empty = all events)
27    pub event_types: Vec<String>,
28    /// Whether the webhook is active
29    pub active: bool,
30    /// Custom headers to include
31    pub headers: HashMap<String, String>,
32    /// Created timestamp
33    pub created_at: DateTime<Utc>,
34}
35
36/// Reasons webhook registration failed.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum WebhookRegistrationError {
40    /// The webhook URL is considered unsafe (e.g. localhost, private network, invalid scheme).
41    UnsafeUrl,
42    /// A webhook with this ID already exists.
43    DuplicateId,
44    /// Webhooks are disabled in the event system configuration.
45    WebhooksDisabled,
46}
47
48impl fmt::Display for WebhookRegistrationError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::UnsafeUrl => {
52                write!(f, "webhook registration rejected: URL validation failed")
53            }
54            Self::DuplicateId => {
55                write!(f, "webhook registration rejected: duplicate webhook id")
56            }
57            Self::WebhooksDisabled => {
58                write!(f, "webhook registration rejected: webhook subsystem is disabled")
59            }
60        }
61    }
62}
63
64impl Webhook {
65    /// Create a new webhook
66    pub fn new(name: impl Into<String>, url: impl Into<String>) -> Self {
67        Self {
68            id: Uuid::new_v4(),
69            name: name.into(),
70            url: url.into(),
71            secret: None,
72            event_types: Vec::new(),
73            active: true,
74            headers: HashMap::new(),
75            created_at: Utc::now(),
76        }
77    }
78
79    /// Set the secret for HMAC signature
80    pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
81        self.secret = Some(secret.into());
82        self
83    }
84
85    /// Filter to specific event types
86    #[must_use]
87    pub fn with_events(mut self, events: Vec<String>) -> Self {
88        self.event_types = events;
89        self
90    }
91
92    /// Add a custom header
93    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
94        self.headers.insert(key.into(), value.into());
95        self
96    }
97
98    /// Check if this webhook should receive an event
99    #[must_use]
100    pub fn should_receive(&self, event: &CommerceEvent) -> bool {
101        if !self.active {
102            return false;
103        }
104        if self.event_types.is_empty() {
105            return true;
106        }
107        self.event_types.contains(&event.event_type().to_string())
108    }
109}
110
111/// Configuration for webhook delivery
112#[derive(Debug, Clone)]
113pub struct WebhookConfig {
114    pub max_retries: u32,
115    pub timeout_secs: u64,
116    pub retry_delay_ms: u64,
117    pub max_in_flight: usize,
118    /// Maximum number of delivery records to keep per webhook
119    pub max_delivery_history: usize,
120    /// Optional outbound host allowlist.
121    ///
122    /// When empty, current strict public-host defaults apply.
123    /// When non-empty, hosts must match one of these entries in addition
124    /// to existing SSRF/public-network checks.
125    ///
126    /// Supported entry forms:
127    /// - `example.com` (exact host)
128    /// - `*.example.com` (subdomain match only)
129    pub outbound_allowlist: Vec<String>,
130}
131
132impl Default for WebhookConfig {
133    fn default() -> Self {
134        Self {
135            max_retries: 3,
136            timeout_secs: 30,
137            retry_delay_ms: 1000,
138            max_in_flight: 8,
139            max_delivery_history: 1_000,
140            outbound_allowlist: Vec::new(),
141        }
142    }
143}
144
145impl WebhookConfig {
146    /// Replace outbound allowlist entries with normalized host rules.
147    pub fn with_outbound_allowlist<I, S>(mut self, entries: I) -> Self
148    where
149        I: IntoIterator<Item = S>,
150        S: AsRef<str>,
151    {
152        self.outbound_allowlist = normalize_allowlist(entries);
153        self
154    }
155}
156
157enum WebhookRuntime {
158    Handle(tokio::runtime::Handle),
159    Owned(tokio::runtime::Runtime),
160    Disabled(String),
161}
162
163impl WebhookRuntime {
164    fn new() -> Self {
165        if let Ok(handle) = tokio::runtime::Handle::try_current() {
166            Self::Handle(handle)
167        } else {
168            match tokio::runtime::Runtime::new() {
169                Ok(runtime) => Self::Owned(runtime),
170                Err(err) => {
171                    let fallback =
172                        tokio::runtime::Builder::new_current_thread().enable_all().build();
173                    match fallback {
174                        Ok(runtime) => Self::Owned(runtime),
175                        Err(fallback_err) => {
176                            let message = format!(
177                                "Failed to create webhook runtime: {err}; fallback failed: {fallback_err}"
178                            );
179                            tracing::error!("{}", message);
180                            Self::Disabled(message)
181                        }
182                    }
183                }
184            }
185        }
186    }
187
188    fn spawn<F>(&self, fut: F)
189    where
190        F: Future + Send + 'static,
191        F::Output: Send + 'static,
192    {
193        match self {
194            Self::Handle(handle) => {
195                handle.spawn(fut);
196            }
197            Self::Owned(runtime) => {
198                runtime.spawn(fut);
199            }
200            Self::Disabled(message) => {
201                tracing::error!("Webhook runtime unavailable: {}", message);
202            }
203        }
204    }
205}
206
207/// Webhook delivery attempt record
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct WebhookDelivery {
210    pub id: Uuid,
211    pub webhook_id: Uuid,
212    pub event_type: String,
213    pub event_id: Uuid,
214    pub status: DeliveryStatus,
215    pub attempts: u32,
216    pub last_attempt_at: Option<DateTime<Utc>>,
217    pub response_status: Option<u16>,
218    pub response_body: Option<String>,
219    pub error: Option<String>,
220    pub created_at: DateTime<Utc>,
221}
222
223/// Delivery status
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225#[serde(rename_all = "snake_case")]
226#[non_exhaustive]
227pub enum DeliveryStatus {
228    Pending,
229    Delivered,
230    Failed,
231    Retrying,
232}
233
234/// Webhook manager handles registration and delivery
235pub struct WebhookManager {
236    webhooks: Arc<RwLock<HashMap<Uuid, Webhook>>>,
237    delivery_history: Arc<RwLock<HashMap<Uuid, VecDeque<WebhookDelivery>>>>,
238    config: WebhookConfig,
239    client: reqwest::Client,
240    runtime: WebhookRuntime,
241    delivery_task_limit: Arc<Semaphore>,
242}
243
244impl std::fmt::Debug for WebhookManager {
245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246        f.debug_struct("WebhookManager").field("config", &self.config).finish_non_exhaustive()
247    }
248}
249
250impl WebhookManager {
251    /// Create a new webhook manager
252    #[must_use]
253    pub fn new(max_retries: u32, timeout_secs: u64) -> Self {
254        Self::with_config(WebhookConfig { max_retries, timeout_secs, ..Default::default() })
255    }
256
257    /// Create a new webhook manager with full configuration.
258    #[must_use]
259    pub fn with_config(config: WebhookConfig) -> Self {
260        let mut config = WebhookConfig {
261            max_in_flight: config.max_in_flight.max(1),
262            timeout_secs: config.timeout_secs.max(1),
263            retry_delay_ms: config.retry_delay_ms.max(1),
264            ..config
265        };
266        config.outbound_allowlist = normalize_allowlist(config.outbound_allowlist.iter());
267
268        let client = reqwest::Client::builder()
269            .timeout(std::time::Duration::from_secs(config.timeout_secs))
270            .redirect(reqwest::redirect::Policy::none())
271            .build()
272            .unwrap_or_else(|err| {
273                tracing::error!("Failed to create HTTP client: {}", err);
274                reqwest::Client::new()
275            });
276        let runtime = WebhookRuntime::new();
277        let delivery_task_limit = Arc::new(Semaphore::new(config.max_in_flight.max(1)));
278
279        Self {
280            webhooks: Arc::new(RwLock::new(HashMap::new())),
281            delivery_history: Arc::new(RwLock::new(HashMap::new())),
282            config,
283            client,
284            runtime,
285            delivery_task_limit,
286        }
287    }
288
289    /// Register a new webhook using the legacy API.
290    ///
291    /// Returns `Uuid::nil()` on failure to preserve compatibility with
292    /// older call sites. Use [`register_strict`](Self::register_strict) or
293    /// [`try_register`](Self::try_register) for explicit handling.
294    pub fn register(&self, webhook: Webhook) -> Uuid {
295        match self.register_strict(webhook) {
296            Ok(id) => id,
297            Err(err) => {
298                tracing::warn!(error = %err, "Webhook registration fallback returned nil");
299                Uuid::nil()
300            }
301        }
302    }
303
304    /// Register a new webhook with explicit failure semantics.
305    ///
306    /// Returns a registration error when the URL is rejected.
307    pub fn register_strict(&self, webhook: Webhook) -> Result<Uuid, WebhookRegistrationError> {
308        if !is_safe_webhook_url(&webhook.url, &self.config.outbound_allowlist) {
309            tracing::warn!(
310                webhook_id = %webhook.id,
311                webhook_url = %webhook.url,
312                "Rejected webhook registration: unsafe URL"
313            );
314            return Err(WebhookRegistrationError::UnsafeUrl);
315        }
316
317        let id = webhook.id;
318        let mut webhooks = match self.webhooks.write() {
319            Ok(guard) => guard,
320            Err(poison) => {
321                tracing::error!("WebhookManager webhooks lock poisoned (write); recovering");
322                poison.into_inner()
323            }
324        };
325        if webhooks.contains_key(&id) {
326            tracing::warn!(webhook_id = %id, "Rejected webhook registration: duplicate id");
327            return Err(WebhookRegistrationError::DuplicateId);
328        }
329        webhooks.insert(id, webhook);
330        tracing::info!(webhook_id = %id, "Webhook registered");
331        Ok(id)
332    }
333
334    /// Register a new webhook, returning `None` when URL validation fails.
335    pub fn try_register(&self, webhook: Webhook) -> Option<Uuid> {
336        self.register_strict(webhook).ok()
337    }
338
339    /// Unregister a webhook
340    pub fn unregister(&self, id: Uuid) -> bool {
341        let mut webhooks = match self.webhooks.write() {
342            Ok(guard) => guard,
343            Err(poison) => {
344                tracing::error!("WebhookManager webhooks lock poisoned (write); recovering");
345                poison.into_inner()
346            }
347        };
348        let removed = webhooks.remove(&id).is_some();
349        if removed {
350            let mut delivery_history = match self.delivery_history.write() {
351                Ok(guard) => guard,
352                Err(poison) => {
353                    tracing::error!(
354                        "WebhookManager delivery history lock poisoned (write); recovering"
355                    );
356                    poison.into_inner()
357                }
358            };
359            delivery_history.remove(&id);
360            tracing::info!(webhook_id = %id, "Webhook unregistered");
361        }
362        removed
363    }
364
365    /// Get a webhook by ID
366    pub fn get(&self, id: Uuid) -> Option<Webhook> {
367        let webhooks = match self.webhooks.read() {
368            Ok(guard) => guard,
369            Err(poison) => {
370                tracing::error!("WebhookManager webhooks lock poisoned (read); recovering");
371                poison.into_inner()
372            }
373        };
374        webhooks.get(&id).cloned()
375    }
376
377    /// List all webhooks
378    pub fn list(&self) -> Vec<Webhook> {
379        let webhooks = match self.webhooks.read() {
380            Ok(guard) => guard,
381            Err(poison) => {
382                tracing::error!("WebhookManager webhooks lock poisoned (read); recovering");
383                poison.into_inner()
384            }
385        };
386        webhooks.values().cloned().collect()
387    }
388
389    /// Get delivery history for a webhook (newest-first)
390    pub fn deliveries(&self, webhook_id: Uuid) -> Vec<WebhookDelivery> {
391        let history = match self.delivery_history.read() {
392            Ok(guard) => guard,
393            Err(poison) => {
394                tracing::error!("WebhookManager delivery history lock poisoned (read); recovering");
395                poison.into_inner()
396            }
397        };
398        history
399            .get(&webhook_id)
400            .map(|entries| entries.iter().rev().cloned().collect())
401            .unwrap_or_default()
402    }
403
404    /// Update a webhook
405    pub fn update(&self, webhook: Webhook) -> bool {
406        let id = webhook.id;
407        let mut webhooks = match self.webhooks.write() {
408            Ok(guard) => guard,
409            Err(poison) => {
410                tracing::error!("WebhookManager webhooks lock poisoned (write); recovering");
411                poison.into_inner()
412            }
413        };
414        if !is_safe_webhook_url(&webhook.url, &self.config.outbound_allowlist) {
415            tracing::warn!(webhook_id = %webhook.id, webhook_url = %webhook.url, "Rejected webhook update: unsafe URL");
416            return false;
417        }
418
419        if let std::collections::hash_map::Entry::Occupied(mut e) = webhooks.entry(id) {
420            e.insert(webhook);
421            true
422        } else {
423            false
424        }
425    }
426
427    /// Enable/disable a webhook
428    pub fn set_active(&self, id: Uuid, active: bool) -> bool {
429        let mut webhooks = match self.webhooks.write() {
430            Ok(guard) => guard,
431            Err(poison) => {
432                tracing::error!("WebhookManager webhooks lock poisoned (write); recovering");
433                poison.into_inner()
434            }
435        };
436        if let Some(webhook) = webhooks.get_mut(&id) {
437            webhook.active = active;
438            true
439        } else {
440            false
441        }
442    }
443
444    /// Get all failed deliveries across all webhooks.
445    #[must_use]
446    pub fn failed_deliveries(&self) -> Vec<WebhookDelivery> {
447        let history = match self.delivery_history.read() {
448            Ok(guard) => guard,
449            Err(poison) => poison.into_inner(),
450        };
451        history
452            .values()
453            .flat_map(|entries| {
454                entries.iter().filter(|d| d.status == DeliveryStatus::Failed).cloned()
455            })
456            .collect()
457    }
458
459    /// Count of failed deliveries (for monitoring dashboards).
460    #[must_use]
461    pub fn failed_delivery_count(&self) -> usize {
462        let history = match self.delivery_history.read() {
463            Ok(guard) => guard,
464            Err(poison) => poison.into_inner(),
465        };
466        history
467            .values()
468            .flat_map(|entries| entries.iter())
469            .filter(|d| d.status == DeliveryStatus::Failed)
470            .count()
471    }
472
473    /// Clear failed deliveries for a specific webhook (after manual review).
474    pub fn clear_failed_deliveries(&self, webhook_id: Uuid) -> usize {
475        let mut history = match self.delivery_history.write() {
476            Ok(guard) => guard,
477            Err(poison) => poison.into_inner(),
478        };
479        if let Some(entries) = history.get_mut(&webhook_id) {
480            let before = entries.len();
481            entries.retain(|d| d.status != DeliveryStatus::Failed);
482            before - entries.len()
483        } else {
484            0
485        }
486    }
487
488    /// Deliver an event to all matching webhooks (spawns async tasks)
489    pub fn deliver(&self, event: CommerceEvent) {
490        use futures::stream::{self, StreamExt};
491
492        let webhooks_guard = match self.webhooks.read() {
493            Ok(guard) => guard,
494            Err(poison) => {
495                tracing::error!("WebhookManager webhooks lock poisoned (read); recovering");
496                poison.into_inner()
497            }
498        };
499        let webhooks: Vec<Webhook> =
500            webhooks_guard.values().filter(|w| w.should_receive(&event)).cloned().collect();
501
502        if webhooks.is_empty() {
503            return;
504        }
505
506        let client = self.client.clone();
507        let config = self.config.clone();
508        let delivery_history = self.delivery_history.clone();
509        let max_in_flight = config.max_in_flight.max(1);
510        let Some(delivery_permit) = self.delivery_task_limit.clone().try_acquire_owned().ok()
511        else {
512            tracing::warn!(
513                event_type = event.event_type(),
514                "Dropping webhook delivery due to saturated delivery workers"
515            );
516            return;
517        };
518
519        self.runtime.spawn(async move {
520            let _delivery_permit = delivery_permit;
521            let deliveries = stream::iter(webhooks.into_iter().map(|webhook| {
522                let client = client.clone();
523                let event = event.clone();
524                let config = config.clone();
525                let delivery_history = delivery_history.clone();
526                async move {
527                    deliver_to_webhook(&client, &webhook, &event, &config, &delivery_history).await;
528                }
529            }));
530
531            deliveries.for_each_concurrent(max_in_flight, |fut| fut).await;
532        });
533    }
534}
535
536/// Deliver an event to a specific webhook with retries
537async fn deliver_to_webhook(
538    client: &reqwest::Client,
539    webhook: &Webhook,
540    event: &CommerceEvent,
541    config: &WebhookConfig,
542    delivery_history: &Arc<RwLock<HashMap<Uuid, VecDeque<WebhookDelivery>>>>,
543) {
544    let mut delivery = WebhookDelivery {
545        id: Uuid::new_v4(),
546        webhook_id: webhook.id,
547        event_type: event.event_type().to_string(),
548        event_id: Uuid::new_v4(),
549        status: DeliveryStatus::Pending,
550        attempts: 0,
551        last_attempt_at: None,
552        response_status: None,
553        response_body: None,
554        error: None,
555        created_at: Utc::now(),
556    };
557
558    if !is_safe_webhook_url_for_delivery(&webhook.url, &config.outbound_allowlist) {
559        tracing::error!(
560            webhook_id = %webhook.id,
561            webhook_url = %webhook.url,
562            "Skipping webhook delivery: unsafe URL"
563        );
564        delivery.status = DeliveryStatus::Failed;
565        delivery.error = Some("unsafe webhook URL".to_string());
566        append_delivery_record(delivery_history, webhook.id, delivery, config.max_delivery_history);
567        return;
568    }
569
570    let payload = WebhookPayload {
571        id: Uuid::new_v4(),
572        event_type: event.event_type().to_string(),
573        timestamp: event.timestamp(),
574        data: event.clone(),
575    };
576    delivery.event_id = payload.id;
577
578    let body = match serde_json::to_string(&payload) {
579        Ok(b) => b,
580        Err(e) => {
581            tracing::error!(error = %e, "Failed to serialize webhook payload");
582            delivery.status = DeliveryStatus::Failed;
583            delivery.error = Some(format!("failed to serialize payload: {e}"));
584            append_delivery_record(
585                delivery_history,
586                webhook.id,
587                delivery,
588                config.max_delivery_history,
589            );
590            return;
591        }
592    };
593
594    for attempt in 0..=config.max_retries {
595        if attempt > 0 {
596            // Exponential backoff with saturation + cap to avoid overflows or ridiculous delays.
597            const MAX_BACKOFF_MS: u64 = 60_000;
598            let exp = attempt.saturating_sub(1);
599            let shift = exp.min(16);
600            let factor = 1u64.checked_shl(shift).unwrap_or(u64::MAX);
601            let delay_ms = config.retry_delay_ms.saturating_mul(factor).min(MAX_BACKOFF_MS);
602            tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
603        }
604
605        begin_delivery_attempt(&mut delivery, attempt + 1);
606
607        let mut request = client
608            .post(&webhook.url)
609            .header("Content-Type", "application/json")
610            .header("X-Webhook-ID", webhook.id.to_string())
611            .header("X-Event-Type", event.event_type())
612            .header("X-Delivery-ID", payload.id.to_string())
613            .header("X-Delivery-Attempt", (attempt + 1).to_string());
614
615        // Add HMAC signature if secret is configured
616        if let Some(ref secret) = webhook.secret {
617            if let Some(signature) = compute_signature(secret, &body) {
618                request = request.header("X-Signature", signature);
619            }
620        }
621
622        // Add custom headers
623        for (key, value) in &webhook.headers {
624            request = request.header(key, value);
625        }
626
627        match request.body(body.clone()).send().await {
628            Ok(response) => {
629                let status = response.status();
630                delivery.response_status = Some(status.as_u16());
631                if status.is_success() {
632                    delivery.status = DeliveryStatus::Delivered;
633                    tracing::debug!(
634                        webhook_id = %webhook.id,
635                        event_type = event.event_type(),
636                        status = %status,
637                        attempt = attempt + 1,
638                        "Webhook delivered successfully"
639                    );
640                    append_delivery_record(
641                        delivery_history,
642                        webhook.id,
643                        delivery,
644                        config.max_delivery_history,
645                    );
646                    return;
647                } else {
648                    let response_body = response.text().await.unwrap_or_default();
649                    delivery.response_body = Some(response_body.clone());
650                    if attempt >= config.max_retries {
651                        delivery.status = DeliveryStatus::Failed;
652                    } else {
653                        delivery.status = DeliveryStatus::Retrying;
654                    }
655                    tracing::warn!(
656                        webhook_id = %webhook.id,
657                        event_type = event.event_type(),
658                        status = %status,
659                        attempt = attempt + 1,
660                        response = %response_body,
661                        "Webhook delivery failed with non-success status"
662                    );
663                }
664            }
665            Err(e) => {
666                delivery.error = Some(e.to_string());
667                if attempt >= config.max_retries {
668                    delivery.status = DeliveryStatus::Failed;
669                } else {
670                    delivery.status = DeliveryStatus::Retrying;
671                }
672                tracing::warn!(
673                    webhook_id = %webhook.id,
674                    event_type = event.event_type(),
675                    attempt = attempt + 1,
676                    error = %e,
677                    "Webhook delivery failed"
678                );
679            }
680        }
681
682        if delivery.status == DeliveryStatus::Failed && attempt >= config.max_retries {
683            break;
684        }
685    }
686
687    append_delivery_record(delivery_history, webhook.id, delivery, config.max_delivery_history);
688    tracing::error!(
689        webhook_id = %webhook.id,
690        event_type = event.event_type(),
691        max_retries = config.max_retries,
692        "Webhook delivery exhausted all retries"
693    );
694}
695
696fn begin_delivery_attempt(delivery: &mut WebhookDelivery, attempt: u32) {
697    delivery.attempts = attempt;
698    delivery.last_attempt_at = Some(Utc::now());
699    // Keep only metadata from the active attempt.
700    delivery.response_status = None;
701    delivery.response_body = None;
702    delivery.error = None;
703}
704
705fn append_delivery_record(
706    delivery_history: &Arc<RwLock<HashMap<Uuid, VecDeque<WebhookDelivery>>>>,
707    webhook_id: Uuid,
708    delivery: WebhookDelivery,
709    max_delivery_history: usize,
710) {
711    let mut history = match delivery_history.write() {
712        Ok(guard) => guard,
713        Err(poison) => {
714            tracing::error!("WebhookManager delivery history lock poisoned (write); recovering");
715            poison.into_inner()
716        }
717    };
718    let entries = history.entry(webhook_id).or_insert_with(VecDeque::new);
719    entries.push_back(delivery);
720
721    while entries.len() > max_delivery_history {
722        entries.pop_front();
723    }
724}
725
726fn normalize_allowlist<I, S>(entries: I) -> Vec<String>
727where
728    I: IntoIterator<Item = S>,
729    S: AsRef<str>,
730{
731    let mut normalized = Vec::new();
732    for entry in entries {
733        let candidate = normalize_host_match_value(entry.as_ref());
734        if candidate.is_empty() {
735            continue;
736        }
737        if !normalized.contains(&candidate) {
738            normalized.push(candidate);
739        }
740    }
741    normalized
742}
743
744fn normalize_host_match_value(value: &str) -> String {
745    let trimmed = value.trim();
746    if let Some(suffix) = trimmed.strip_prefix("*.") {
747        let suffix = suffix.trim_end_matches('.').to_ascii_lowercase();
748        if suffix.is_empty() {
749            return String::new();
750        }
751        return format!("*.{suffix}");
752    }
753    trimmed.trim_end_matches('.').to_ascii_lowercase()
754}
755
756fn host_matches_allowlist(host: &str, allowlist: &[String]) -> bool {
757    let normalized_host = normalize_host_match_value(host);
758
759    allowlist.iter().any(|entry| allowlist_entry_matches(normalized_host.as_str(), entry.as_str()))
760}
761
762fn allowlist_entry_matches(host: &str, entry: &str) -> bool {
763    if let Some(suffix) = entry.strip_prefix("*.") {
764        // Subdomain-only wildcard. `*.example.com` does not match `example.com`.
765        return host.len() > suffix.len()
766            && host.ends_with(suffix)
767            && host
768                .as_bytes()
769                .get(host.len().saturating_sub(suffix.len() + 1))
770                .is_some_and(|b| *b == b'.');
771    }
772
773    host == entry
774}
775
776fn is_safe_webhook_url(url: &str, outbound_allowlist: &[String]) -> bool {
777    // Registration should not depend on live DNS resolution because that creates
778    // environment-dependent false negatives (offline CI/dev shells). We still
779    // enforce strict scheme/host/IP checks here and apply DNS resolution in the
780    // delivery path.
781    is_safe_webhook_url_with_dns_check(url, false, outbound_allowlist)
782}
783
784fn is_safe_webhook_url_for_delivery(url: &str, outbound_allowlist: &[String]) -> bool {
785    is_safe_webhook_url_with_dns_check(url, true, outbound_allowlist)
786}
787
788pub(crate) fn validate_outbound_webhook_target_url(
789    url: &str,
790    outbound_allowlist: &[String],
791) -> Result<(), String> {
792    if is_safe_webhook_url_for_delivery(url, outbound_allowlist) {
793        Ok(())
794    } else {
795        Err("URL failed outbound webhook safety validation".to_string())
796    }
797}
798
799fn is_safe_webhook_url_with_dns_check(
800    url: &str,
801    resolve_dns: bool,
802    outbound_allowlist: &[String],
803) -> bool {
804    is_safe_webhook_url_with_dns_resolver(url, resolve_dns, outbound_allowlist, &resolve_host_ips)
805}
806
807fn is_safe_webhook_url_with_dns_resolver<F>(
808    url: &str,
809    resolve_dns: bool,
810    outbound_allowlist: &[String],
811    dns_resolver: &F,
812) -> bool
813where
814    F: Fn(&str, u16) -> Result<Vec<IpAddr>, String>,
815{
816    let parsed = match Url::parse(url) {
817        Ok(parsed) => parsed,
818        Err(err) => {
819            tracing::warn!(webhook_url = %url, error = %err, "Webhook URL parse failed");
820            return false;
821        }
822    };
823
824    if !matches!(parsed.scheme(), "http" | "https") {
825        tracing::warn!(webhook_url = %url, scheme = %parsed.scheme(), "Webhook URL scheme rejected");
826        return false;
827    }
828
829    if parsed.password().is_some() || !parsed.username().is_empty() {
830        tracing::warn!(webhook_url = %url, "Webhook URL userinfo rejected");
831        return false;
832    }
833
834    let host = if let Some(host) = parsed.host_str() {
835        host
836    } else {
837        tracing::warn!(webhook_url = %url, "Webhook URL host missing");
838        return false;
839    };
840
841    if is_localhostish_host(host) {
842        tracing::warn!(webhook_url = %url, "Webhook URL host rejected (localhost-like)");
843        return false;
844    }
845
846    // Reject all IPv6-mapped IPv4 literals up front (`::ffff:...`). Even when
847    // syntactically valid, these are unnecessary for webhook endpoints and are
848    // a common bypass vector for localhost/private filtering logic.
849    let normalized_host = host.trim().trim_matches('[').trim_matches(']').to_ascii_lowercase();
850    if normalized_host.contains("::ffff:") {
851        tracing::warn!(webhook_url = %url, "Webhook URL host rejected (IPv6-mapped IPv4 literal)");
852        return false;
853    }
854
855    // Some `host_str` forms of IPv6-mapped IPv4 literals can evade strict
856    // `IpAddr` parsing depending on representation. Handle dotted suffix forms
857    // explicitly to keep registration checks deterministic.
858    if let Some(mapped_v4) = parse_ipv6_mapped_ipv4_host(host) {
859        if is_public_ipv4(mapped_v4) {
860            return true;
861        }
862        tracing::warn!(
863            webhook_url = %url,
864            mapped_ipv4 = %mapped_v4,
865            "Webhook URL host rejected (IPv6-mapped non-public IPv4)"
866        );
867        return false;
868    }
869
870    if parse_decimal_ipv4_literal(host).is_some() || is_ambiguous_ipv4_encoding_host(host) {
871        tracing::warn!(webhook_url = %url, "Webhook URL host rejected (ambiguous IPv4 encoding)");
872        return false;
873    }
874
875    if !outbound_allowlist.is_empty() && !host_matches_allowlist(host, outbound_allowlist) {
876        tracing::warn!(webhook_url = %url, "Webhook URL host rejected (not in outbound allowlist)");
877        return false;
878    }
879
880    let host_for_ip_parse = host.trim().trim_matches('[').trim_matches(']');
881    if let Ok(ipv6) = host_for_ip_parse.parse::<Ipv6Addr>() {
882        if is_public_ipv6(ipv6) {
883            return true;
884        }
885        tracing::warn!(webhook_url = %url, ip = %ipv6, "Webhook URL host IP rejected");
886        return false;
887    }
888    if let Ok(ipv4) = host_for_ip_parse.parse::<Ipv4Addr>() {
889        if is_public_ipv4(ipv4) {
890            return true;
891        }
892        tracing::warn!(webhook_url = %url, ip = %ipv4, "Webhook URL host IP rejected");
893        return false;
894    }
895
896    if resolve_dns {
897        is_public_hostname_with_resolver(
898            host,
899            parsed.port_or_known_default().unwrap_or(443),
900            dns_resolver,
901        )
902    } else {
903        true
904    }
905}
906
907fn parse_ipv6_mapped_ipv4_host(host: &str) -> Option<Ipv4Addr> {
908    let normalized = host.trim().trim_matches('[').trim_matches(']').to_ascii_lowercase();
909    let mapped = normalized.strip_prefix("::ffff:")?;
910    mapped.parse::<Ipv4Addr>().ok()
911}
912
913fn is_localhostish_host(host: &str) -> bool {
914    let normalized = host.trim().trim_end_matches('.').to_ascii_lowercase();
915
916    normalized == "localhost"
917        || normalized == "localhost.localdomain"
918        || normalized.ends_with(".localhost")
919        || normalized.ends_with(".local")
920}
921
922fn is_public_hostname_with_resolver<F>(host: &str, port: u16, dns_resolver: &F) -> bool
923where
924    F: Fn(&str, u16) -> Result<Vec<IpAddr>, String>,
925{
926    let resolved = match dns_resolver(host, port) {
927        Ok(ips) => ips,
928        Err(err) => {
929            tracing::warn!(webhook_host = %host, error = %err, "Webhook hostname DNS resolution failed");
930            return false;
931        }
932    };
933
934    if resolved.is_empty() {
935        tracing::warn!(webhook_host = %host, "Webhook hostname did not resolve to any address");
936        return false;
937    }
938
939    for ip in &resolved {
940        if !is_public_ip(*ip) {
941            tracing::warn!(webhook_host = %host, ip = %ip, "Webhook hostname resolves to private or local network addresses");
942            return false;
943        }
944    }
945
946    true
947}
948
949fn resolve_host_ips(host: &str, port: u16) -> Result<Vec<IpAddr>, String> {
950    match (host, port).to_socket_addrs() {
951        Ok(addrs) => {
952            let mut ips = Vec::new();
953            for socket_addr in addrs {
954                let ip = socket_addr.ip();
955                if !ips.contains(&ip) {
956                    ips.push(ip);
957                }
958            }
959            Ok(ips)
960        }
961        Err(err) => Err(err.to_string()),
962    }
963}
964
965fn parse_decimal_ipv4_literal(host: &str) -> Option<Ipv4Addr> {
966    if !host.bytes().all(|b| b.is_ascii_digit()) {
967        return None;
968    }
969    let n = host.parse::<u32>().ok()?;
970    Some(Ipv4Addr::from(n))
971}
972
973fn is_ambiguous_ipv4_encoding_host(host: &str) -> bool {
974    let labels: Vec<&str> = host.split('.').collect();
975    if labels.is_empty() || labels.len() > 4 {
976        return false;
977    }
978
979    let mut saw_hex = false;
980    for label in &labels {
981        if label.is_empty() {
982            return false;
983        }
984
985        if let Some(hex_digits) = label.strip_prefix("0x") {
986            if hex_digits.is_empty() || !hex_digits.bytes().all(|b| b.is_ascii_hexdigit()) {
987                return false;
988            }
989            saw_hex = true;
990            continue;
991        }
992
993        if !label.bytes().all(|b| b.is_ascii_digit()) {
994            return false;
995        }
996    }
997
998    if labels.len() == 1 {
999        return saw_hex;
1000    }
1001
1002    saw_hex
1003        || labels.len() < 4
1004        || labels.iter().any(|label| label.len() > 1 && label.starts_with('0'))
1005}
1006
1007fn is_public_ip(ip: IpAddr) -> bool {
1008    match ip {
1009        IpAddr::V4(addr) => is_public_ipv4(addr),
1010        IpAddr::V6(addr) => is_public_ipv6(addr),
1011    }
1012}
1013
1014fn is_public_ipv4(addr: Ipv4Addr) -> bool {
1015    let octets = addr.octets();
1016
1017    // Reject all non-public, local, and special-use ranges.
1018    if addr.is_loopback()
1019        || addr.is_private()
1020        || addr.is_link_local()
1021        || addr.is_multicast()
1022        || addr.is_unspecified()
1023        || addr.is_broadcast()
1024        || addr.is_documentation()
1025    {
1026        return false;
1027    }
1028
1029    // 0.0.0.0/8 ("this" network)
1030    if octets[0] == 0 {
1031        return false;
1032    }
1033
1034    // 100.64.0.0/10 (carrier-grade NAT)
1035    if octets[0] == 100 && (64..=127).contains(&octets[1]) {
1036        return false;
1037    }
1038
1039    // 192.0.0.0/24 (IETF protocol assignments)
1040    if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 {
1041        return false;
1042    }
1043
1044    // 198.18.0.0/15 (benchmarking)
1045    if octets[0] == 198 && (octets[1] == 18 || octets[1] == 19) {
1046        return false;
1047    }
1048
1049    // 240.0.0.0/4 (future use / reserved)
1050    if octets[0] >= 240 {
1051        return false;
1052    }
1053
1054    true
1055}
1056
1057const fn is_public_ipv6(addr: Ipv6Addr) -> bool {
1058    if addr.to_ipv4_mapped().is_some() {
1059        return false;
1060    }
1061
1062    let segments = addr.segments();
1063    let first = segments[0];
1064
1065    // fc00::/7 unique-local, fe80::/10 link-local, fec0::/10 site-local (deprecated)
1066    let is_unique_local = (first & 0xfe00) == 0xfc00;
1067    let is_link_local = (first & 0xffc0) == 0xfe80;
1068    let is_site_local = (first & 0xffc0) == 0xfec0;
1069    // 2001:db8::/32 documentation
1070    let is_documentation = first == 0x2001 && segments[1] == 0x0db8;
1071
1072    !addr.is_loopback()
1073        && !addr.is_unspecified()
1074        && !addr.is_multicast()
1075        && !is_unique_local
1076        && !is_link_local
1077        && !is_site_local
1078        && !is_documentation
1079}
1080
1081/// Webhook payload wrapper
1082#[derive(Debug, Serialize, Deserialize)]
1083struct WebhookPayload {
1084    id: Uuid,
1085    event_type: String,
1086    timestamp: DateTime<Utc>,
1087    data: CommerceEvent,
1088}
1089
1090/// Compute HMAC-SHA256 signature for webhook payload
1091fn compute_signature(secret: &str, body: &str) -> Option<String> {
1092    use hmac::{Hmac, Mac};
1093    use sha2::Sha256;
1094
1095    type HmacSha256 = Hmac<Sha256>;
1096
1097    let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
1098        Ok(mac) => mac,
1099        Err(err) => {
1100            tracing::error!(error = %err, "Failed to initialize webhook HMAC");
1101            return None;
1102        }
1103    };
1104    mac.update(body.as_bytes());
1105    let result = mac.finalize();
1106
1107    Some(format!("sha256={}", hex::encode(result.into_bytes())))
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113
1114    #[test]
1115    fn test_webhook_creation() {
1116        let webhook = Webhook::new("Test Hook", "https://example.com/webhook")
1117            .with_secret("mysecret")
1118            .with_events(vec!["order_created".to_string()])
1119            .with_header("X-Custom", "value");
1120
1121        assert_eq!(webhook.name, "Test Hook");
1122        assert_eq!(webhook.url, "https://example.com/webhook");
1123        assert_eq!(webhook.secret, Some("mysecret".to_string()));
1124        assert_eq!(webhook.event_types, vec!["order_created"]);
1125        assert_eq!(webhook.headers.get("X-Custom"), Some(&"value".to_string()));
1126    }
1127
1128    #[test]
1129    fn test_webhook_should_receive() {
1130        use chrono::Utc;
1131        use rust_decimal_macros::dec;
1132
1133        let webhook = Webhook::new("Test", "https://example.com")
1134            .with_events(vec!["order_created".to_string()]);
1135
1136        let order_event = CommerceEvent::OrderCreated {
1137            order_id: stateset_core::OrderId::new(),
1138            customer_id: stateset_core::CustomerId::new(),
1139            total_amount: dec!(100),
1140            item_count: 1,
1141            timestamp: Utc::now(),
1142        };
1143
1144        let customer_event = CommerceEvent::CustomerCreated {
1145            customer_id: stateset_core::CustomerId::new(),
1146            email: "test@example.com".to_string(),
1147            timestamp: Utc::now(),
1148        };
1149
1150        assert!(webhook.should_receive(&order_event));
1151        assert!(!webhook.should_receive(&customer_event));
1152    }
1153
1154    #[test]
1155    fn test_compute_signature() {
1156        let signature = compute_signature("secret", "test body").expect("signature");
1157        assert!(signature.starts_with("sha256="));
1158    }
1159
1160    #[test]
1161    fn test_webhook_register_rejects_localhost() {
1162        let manager = WebhookManager::new(3, 30);
1163        let webhook = Webhook::new("Localhost Hook", "http://localhost:8080/webhook");
1164
1165        assert_eq!(
1166            manager.register_strict(webhook.clone()),
1167            Err(WebhookRegistrationError::UnsafeUrl)
1168        );
1169        assert!(manager.try_register(webhook).is_none());
1170        assert!(manager.list().is_empty());
1171    }
1172
1173    #[test]
1174    fn test_webhook_register_rejects_private_ip() {
1175        let manager = WebhookManager::new(3, 30);
1176        let webhook = Webhook::new("Private IP Hook", "https://10.0.0.1/callback");
1177
1178        assert_eq!(
1179            manager.register_strict(webhook.clone()),
1180            Err(WebhookRegistrationError::UnsafeUrl)
1181        );
1182        assert!(manager.try_register(webhook).is_none());
1183        assert!(manager.list().is_empty());
1184    }
1185
1186    #[test]
1187    fn test_webhook_register_rejects_ipv6_mapped_ipv4_loopback() {
1188        let manager = WebhookManager::new(3, 30);
1189        let webhook = Webhook::new("IPv6 mapped loopback hook", "https://[::ffff:127.0.0.1]/cb");
1190
1191        assert_eq!(
1192            manager.register_strict(webhook.clone()),
1193            Err(WebhookRegistrationError::UnsafeUrl)
1194        );
1195        assert!(manager.try_register(webhook).is_none());
1196    }
1197
1198    #[test]
1199    fn test_webhook_register_rejects_ipv6_mapped_ipv4_private() {
1200        let manager = WebhookManager::new(3, 30);
1201        let webhook = Webhook::new("IPv6 mapped private hook", "https://[::ffff:10.0.0.1]/cb");
1202
1203        assert_eq!(
1204            manager.register_strict(webhook.clone()),
1205            Err(WebhookRegistrationError::UnsafeUrl)
1206        );
1207        assert!(manager.try_register(webhook).is_none());
1208    }
1209
1210    #[test]
1211    fn test_webhook_register_rejects_shared_ipv4_space() {
1212        let manager = WebhookManager::new(3, 30);
1213        let webhook = Webhook::new("CGNAT hook", "https://100.64.0.1/cb");
1214
1215        assert_eq!(
1216            manager.register_strict(webhook.clone()),
1217            Err(WebhookRegistrationError::UnsafeUrl)
1218        );
1219        assert!(manager.try_register(webhook).is_none());
1220    }
1221
1222    #[test]
1223    fn test_webhook_register_accepts_public_ipv6() {
1224        let manager = WebhookManager::new(3, 30);
1225        let webhook = Webhook::new("Public IPv6 Hook", "https://[2606:4700:4700::1111]/callback");
1226        let id = manager.try_register(webhook).expect("expected public IPv6 to be accepted");
1227        assert_ne!(id, Uuid::nil());
1228    }
1229
1230    #[test]
1231    fn test_webhook_config_normalizes_runtime_limits() {
1232        let manager = WebhookManager::with_config(WebhookConfig {
1233            max_retries: 4,
1234            timeout_secs: 0,
1235            max_in_flight: 0,
1236            retry_delay_ms: 0,
1237            max_delivery_history: 7,
1238            outbound_allowlist: vec![
1239                "  API.EXAMPLE.COM. ".to_string(),
1240                "*.Example.com.".to_string(),
1241            ],
1242        });
1243
1244        assert_eq!(manager.config.max_retries, 4);
1245        assert_eq!(manager.config.timeout_secs, 1);
1246        assert_eq!(manager.config.max_in_flight, 1);
1247        assert_eq!(manager.config.retry_delay_ms, 1);
1248        assert_eq!(manager.config.max_delivery_history, 7);
1249        assert_eq!(
1250            manager.config.outbound_allowlist,
1251            vec!["api.example.com".to_string(), "*.example.com".to_string()]
1252        );
1253    }
1254
1255    #[test]
1256    fn test_webhook_register_accepts_public_ip() {
1257        let manager = WebhookManager::new(3, 30);
1258        let webhook = Webhook::new("Public IP Hook", "https://1.1.1.1/callback");
1259
1260        let id = manager.try_register(webhook).expect("expected public IP to be accepted");
1261        let registered = manager.get(id).expect("registered webhook should exist");
1262        assert_eq!(registered.url, "https://1.1.1.1/callback");
1263    }
1264
1265    #[test]
1266    fn test_webhook_register_rejects_non_http_scheme() {
1267        let manager = WebhookManager::new(3, 30);
1268        let webhook = Webhook::new("FTP Hook", "ftp://example.com/webhook");
1269
1270        assert_eq!(
1271            manager.register_strict(webhook.clone()),
1272            Err(WebhookRegistrationError::UnsafeUrl)
1273        );
1274        assert!(manager.try_register(webhook).is_none());
1275    }
1276
1277    #[test]
1278    fn test_webhook_register_rejects_userinfo() {
1279        let manager = WebhookManager::new(3, 30);
1280        let webhook = Webhook::new("Userinfo Hook", "https://user:pass@example.com/webhook");
1281
1282        assert_eq!(
1283            manager.register_strict(webhook.clone()),
1284            Err(WebhookRegistrationError::UnsafeUrl)
1285        );
1286        assert!(manager.try_register(webhook).is_none());
1287    }
1288
1289    #[test]
1290    fn test_webhook_register_rejects_invalid_url() {
1291        let manager = WebhookManager::new(3, 30);
1292        let webhook = Webhook::new("Bad URL Hook", "not-a-url");
1293
1294        assert_eq!(
1295            manager.register_strict(webhook.clone()),
1296            Err(WebhookRegistrationError::UnsafeUrl)
1297        );
1298        assert!(manager.try_register(webhook).is_none());
1299    }
1300
1301    #[test]
1302    fn test_webhook_deliveries_keeps_newest_first_and_respects_limit() {
1303        let manager = WebhookManager::with_config(WebhookConfig {
1304            max_delivery_history: 2,
1305            ..Default::default()
1306        });
1307        let webhook = Webhook::new("History Hook", "https://example.com/webhook");
1308        let webhook_id = webhook.id;
1309
1310        let mk_delivery = |record_id: Uuid, status: DeliveryStatus| WebhookDelivery {
1311            id: record_id,
1312            webhook_id,
1313            event_type: "order_created".to_string(),
1314            event_id: Uuid::new_v4(),
1315            status,
1316            attempts: 1,
1317            last_attempt_at: None,
1318            response_status: None,
1319            response_body: None,
1320            error: None,
1321            created_at: Utc::now(),
1322        };
1323
1324        let first = Uuid::new_v4();
1325        let second = Uuid::new_v4();
1326        let third = Uuid::new_v4();
1327
1328        append_delivery_record(
1329            &manager.delivery_history,
1330            webhook_id,
1331            mk_delivery(first, DeliveryStatus::Pending),
1332            2,
1333        );
1334        append_delivery_record(
1335            &manager.delivery_history,
1336            webhook_id,
1337            mk_delivery(second, DeliveryStatus::Delivered),
1338            2,
1339        );
1340        append_delivery_record(
1341            &manager.delivery_history,
1342            webhook_id,
1343            mk_delivery(third, DeliveryStatus::Failed),
1344            2,
1345        );
1346
1347        let deliveries = manager.deliveries(webhook_id);
1348        assert_eq!(deliveries.len(), 2);
1349        assert_eq!(deliveries[0].id, third);
1350        assert_eq!(deliveries[1].id, second);
1351    }
1352
1353    #[test]
1354    fn test_webhook_deliveries_respects_zero_cap() {
1355        let manager = WebhookManager::with_config(WebhookConfig {
1356            max_delivery_history: 0,
1357            ..Default::default()
1358        });
1359        let webhook = Webhook::new("No History Hook", "https://example.com/webhook");
1360        let webhook_id = webhook.id;
1361
1362        append_delivery_record(
1363            &manager.delivery_history,
1364            webhook_id,
1365            WebhookDelivery {
1366                id: Uuid::new_v4(),
1367                webhook_id,
1368                event_type: "order_created".to_string(),
1369                event_id: Uuid::new_v4(),
1370                status: DeliveryStatus::Delivered,
1371                attempts: 1,
1372                last_attempt_at: None,
1373                response_status: None,
1374                response_body: None,
1375                error: None,
1376                created_at: Utc::now(),
1377            },
1378            0,
1379        );
1380
1381        assert_eq!(manager.deliveries(webhook_id).len(), 0);
1382    }
1383
1384    #[test]
1385    fn test_webhook_unregister_clears_delivery_history() {
1386        let manager = WebhookManager::with_config(WebhookConfig {
1387            max_delivery_history: 3,
1388            ..Default::default()
1389        });
1390        let webhook = Webhook::new("History Hook", "https://example.com/webhook");
1391        let webhook_id = webhook.id;
1392        manager.try_register(webhook).expect("webhook should register");
1393
1394        append_delivery_record(
1395            &manager.delivery_history,
1396            webhook_id,
1397            WebhookDelivery {
1398                id: Uuid::new_v4(),
1399                webhook_id,
1400                event_type: "order_created".to_string(),
1401                event_id: Uuid::new_v4(),
1402                status: DeliveryStatus::Delivered,
1403                attempts: 1,
1404                last_attempt_at: None,
1405                response_status: None,
1406                response_body: None,
1407                error: None,
1408                created_at: Utc::now(),
1409            },
1410            3,
1411        );
1412
1413        assert_eq!(manager.deliveries(webhook_id).len(), 1);
1414
1415        assert!(manager.unregister(webhook_id));
1416        assert_eq!(manager.deliveries(webhook_id).len(), 0);
1417        assert!(manager.list().is_empty());
1418    }
1419
1420    #[test]
1421    fn test_webhook_register_fallback_returns_nil() {
1422        let manager = WebhookManager::new(3, 30);
1423        let webhook = Webhook::new("Bad URL Hook", "gopher://127.0.0.1/webhook");
1424        assert_eq!(
1425            manager.register_strict(webhook.clone()),
1426            Err(WebhookRegistrationError::UnsafeUrl)
1427        );
1428        let id = manager.register(webhook);
1429
1430        assert_eq!(id, Uuid::nil());
1431        assert!(manager.list().is_empty());
1432    }
1433
1434    #[test]
1435    fn test_webhook_register_rejects_duplicate_id() {
1436        let manager = WebhookManager::new(3, 30);
1437        let webhook = Webhook::new("Primary", "https://example.com/webhook");
1438        let duplicate = webhook.clone();
1439
1440        let first_id = manager.register_strict(webhook).unwrap();
1441        assert_eq!(first_id, duplicate.id);
1442        assert_eq!(manager.register_strict(duplicate), Err(WebhookRegistrationError::DuplicateId));
1443        assert_eq!(manager.list().len(), 1);
1444    }
1445
1446    #[test]
1447    fn test_begin_delivery_attempt_clears_stale_metadata() {
1448        let mut delivery = WebhookDelivery {
1449            id: Uuid::new_v4(),
1450            webhook_id: Uuid::new_v4(),
1451            event_type: "order_created".to_string(),
1452            event_id: Uuid::new_v4(),
1453            status: DeliveryStatus::Retrying,
1454            attempts: 1,
1455            last_attempt_at: Some(Utc::now()),
1456            response_status: Some(500),
1457            response_body: Some("bad gateway".to_string()),
1458            error: Some("connection refused".to_string()),
1459            created_at: Utc::now(),
1460        };
1461
1462        begin_delivery_attempt(&mut delivery, 2);
1463
1464        assert_eq!(delivery.attempts, 2);
1465        assert!(delivery.last_attempt_at.is_some());
1466        assert_eq!(delivery.response_status, None);
1467        assert_eq!(delivery.response_body, None);
1468        assert_eq!(delivery.error, None);
1469    }
1470
1471    #[test]
1472    fn test_webhook_register_rejects_decimal_ipv4_literal_encoding() {
1473        let manager = WebhookManager::new(3, 30);
1474        let webhook = Webhook::new("Decimal Literal", "https://2130706433/callback");
1475        assert_eq!(manager.try_register(webhook), None);
1476    }
1477
1478    #[test]
1479    fn test_webhook_register_rejects_hex_ipv4_literal_encoding() {
1480        let manager = WebhookManager::new(3, 30);
1481        let webhook = Webhook::new("Hex Literal", "https://0x7f000001/callback");
1482        assert_eq!(manager.try_register(webhook), None);
1483    }
1484
1485    #[test]
1486    fn test_webhook_register_rejects_octal_ipv4_literal_encoding() {
1487        let manager = WebhookManager::new(3, 30);
1488        let webhook = Webhook::new("Octal Literal", "https://0177.0.0.1/callback");
1489        assert_eq!(manager.try_register(webhook), None);
1490    }
1491
1492    #[test]
1493    fn test_webhook_register_rejects_short_ipv4_literal_encoding() {
1494        let manager = WebhookManager::new(3, 30);
1495        let webhook = Webhook::new("Short Literal", "https://127.1/callback");
1496        assert_eq!(manager.try_register(webhook), None);
1497    }
1498
1499    #[test]
1500    fn test_webhook_register_rejects_link_local_ipv6() {
1501        let manager = WebhookManager::new(3, 30);
1502        let webhook = Webhook::new("Link Local", "https://[fe80::1]/callback");
1503        assert_eq!(manager.try_register(webhook), None);
1504    }
1505
1506    #[test]
1507    fn test_webhook_register_rejects_reserved_documentation_ipv4() {
1508        let manager = WebhookManager::new(3, 30);
1509        let webhook = Webhook::new("Doc IPv4", "https://203.0.113.9/callback");
1510        assert_eq!(manager.try_register(webhook), None);
1511    }
1512
1513    #[test]
1514    fn test_webhook_register_rejects_userinfo_confusion_variant() {
1515        let manager = WebhookManager::new(3, 30);
1516        let webhook = Webhook::new("Userinfo Confusion", "https://safe.example.com@127.0.0.1/cb");
1517        assert_eq!(manager.try_register(webhook), None);
1518    }
1519
1520    #[test]
1521    fn test_webhook_register_rejects_malformed_authority() {
1522        let manager = WebhookManager::new(3, 30);
1523        let webhook = Webhook::new("Malformed", "https://[::1/callback");
1524        assert_eq!(manager.try_register(webhook), None);
1525    }
1526
1527    #[test]
1528    fn test_webhook_allowlist_allows_exact_host() {
1529        let manager = WebhookManager::with_config(
1530            WebhookConfig::default().with_outbound_allowlist(["api.example.com"]),
1531        );
1532        let webhook = Webhook::new("Allowlisted", "https://api.example.com/callback");
1533        assert!(manager.try_register(webhook).is_some());
1534    }
1535
1536    #[test]
1537    fn test_webhook_allowlist_blocks_non_listed_host() {
1538        let manager = WebhookManager::with_config(
1539            WebhookConfig::default().with_outbound_allowlist(["api.example.com"]),
1540        );
1541        let webhook = Webhook::new("Blocked", "https://other.example.com/callback");
1542        assert_eq!(manager.try_register(webhook), None);
1543    }
1544
1545    #[test]
1546    fn test_webhook_allowlist_supports_wildcard_subdomain_only() {
1547        let manager = WebhookManager::with_config(
1548            WebhookConfig::default().with_outbound_allowlist(["*.example.com"]),
1549        );
1550        assert!(
1551            manager
1552                .try_register(Webhook::new("Subdomain", "https://hooks.example.com/callback"))
1553                .is_some()
1554        );
1555        assert_eq!(
1556            manager.try_register(Webhook::new("Apex", "https://example.com/callback")),
1557            None
1558        );
1559    }
1560
1561    #[test]
1562    fn test_webhook_allowlist_cannot_override_private_ip_rejection() {
1563        let manager = WebhookManager::with_config(
1564            WebhookConfig::default().with_outbound_allowlist(["127.0.0.1"]),
1565        );
1566        let webhook = Webhook::new("Private", "https://127.0.0.1/callback");
1567        assert_eq!(manager.try_register(webhook), None);
1568    }
1569
1570    #[test]
1571    fn test_webhook_dns_rebinding_style_resolution_mixed_ips_is_rejected() {
1572        let allowlist = vec!["api.example.com".to_string()];
1573        let is_safe = is_safe_webhook_url_with_dns_resolver(
1574            "https://api.example.com/callback",
1575            true,
1576            &allowlist,
1577            &|_host, _port| {
1578                Ok(vec![
1579                    IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)),
1580                    IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
1581                ])
1582            },
1583        );
1584        assert!(!is_safe);
1585    }
1586
1587    #[test]
1588    fn test_webhook_dns_rebinding_style_resolution_all_public_is_allowed() {
1589        let allowlist = vec!["api.example.com".to_string()];
1590        let is_safe = is_safe_webhook_url_with_dns_resolver(
1591            "https://api.example.com/callback",
1592            true,
1593            &allowlist,
1594            &|_host, _port| {
1595                Ok(vec![
1596                    IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)),
1597                    IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)),
1598                ])
1599            },
1600        );
1601        assert!(is_safe);
1602    }
1603}