Skip to main content

axon/
webhooks.rs

1//! Webhooks — outgoing HTTP notification system for AxonServer events.
2//!
3//! Registers webhook endpoints that receive POST notifications when matching
4//! events occur on the EventBus. Each webhook has:
5//!   - URL target
6//!   - Topic filters (exact or prefix match via `*`)
7//!   - Optional HMAC-SHA256 secret for payload signing
8//!   - Active/inactive toggle
9//!   - Delivery history with retry tracking
10//!
11//! Webhooks are dispatched synchronously (recorded for later async delivery).
12//! The registry manages CRUD operations and delivery logging.
13
14use std::collections::HashMap;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use serde::{Deserialize, Serialize};
18
19// ── Types ────────────────────────────────────────────────────────────────
20
21/// A registered webhook configuration.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct WebhookConfig {
24    /// Unique identifier.
25    pub id: String,
26    /// Display name.
27    pub name: String,
28    /// Target URL to POST events to.
29    pub url: String,
30    /// Topic filter patterns (e.g., "deploy", "daemon.*", "*").
31    pub events: Vec<String>,
32    /// Optional HMAC-SHA256 secret for signing payloads.
33    #[serde(skip_serializing)]
34    pub secret: Option<String>,
35    /// Whether this webhook is active.
36    pub active: bool,
37    /// Creation timestamp (Unix seconds).
38    pub created_at: u64,
39    /// Total deliveries attempted.
40    pub delivery_count: u64,
41    /// Total delivery failures.
42    pub failure_count: u64,
43    /// Last delivery timestamp (Unix seconds).
44    pub last_delivery: Option<u64>,
45    /// Optional payload template with variable substitution.
46    /// Variables: {{topic}}, {{timestamp}}, {{source}}, {{payload}}, {{webhook_name}}, {{webhook_id}}.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub template: Option<String>,
49}
50
51/// A webhook delivery record.
52#[derive(Debug, Clone, Serialize)]
53pub struct WebhookDelivery {
54    /// Webhook ID this delivery belongs to.
55    pub webhook_id: String,
56    /// Event topic that triggered the delivery.
57    pub topic: String,
58    /// HTTP status code (0 if connection failed).
59    pub status_code: u16,
60    /// Whether the delivery was successful (2xx).
61    pub success: bool,
62    /// Delivery timestamp (Unix seconds).
63    pub timestamp: u64,
64    /// Latency in milliseconds (0 if not delivered).
65    pub latency_ms: u64,
66    /// Error message if delivery failed.
67    pub error: Option<String>,
68    /// Retry attempt number (0 = first attempt).
69    pub attempt: u32,
70}
71
72/// Summary of a webhook (for listing, secret masked).
73#[derive(Debug, Clone, Serialize)]
74pub struct WebhookSummary {
75    pub id: String,
76    pub name: String,
77    pub url: String,
78    pub events: Vec<String>,
79    pub has_secret: bool,
80    pub active: bool,
81    pub created_at: u64,
82    pub delivery_count: u64,
83    pub failure_count: u64,
84    pub last_delivery: Option<u64>,
85}
86
87/// Result of dispatching an event to webhooks.
88#[derive(Debug, Clone, Serialize)]
89pub struct DispatchResult {
90    /// Number of webhooks that matched the event.
91    pub matched: usize,
92    /// Webhook IDs that matched.
93    pub webhook_ids: Vec<String>,
94}
95
96/// Aggregate statistics across all webhooks.
97#[derive(Debug, Clone, Serialize)]
98pub struct WebhookStats {
99    pub total_webhooks: usize,
100    pub active_webhooks: usize,
101    pub total_deliveries: u64,
102    pub total_failures: u64,
103    pub recent_deliveries: Vec<WebhookDelivery>,
104}
105
106/// A failed delivery queued for retry with exponential backoff.
107#[derive(Debug, Clone, Serialize)]
108pub struct RetryEntry {
109    /// Webhook ID.
110    pub webhook_id: String,
111    /// Event topic.
112    pub topic: String,
113    /// Current retry attempt (starts at 1).
114    pub attempt: u32,
115    /// Maximum retry attempts before dead-lettering.
116    pub max_attempts: u32,
117    /// Unix timestamp of next retry.
118    pub next_retry_at: u64,
119    /// Original failure error.
120    pub original_error: String,
121    /// Unix timestamp when first enqueued.
122    pub enqueued_at: u64,
123}
124
125/// A permanently failed delivery (exceeded max retries).
126#[derive(Debug, Clone, Serialize)]
127pub struct DeadLetterEntry {
128    /// Webhook ID.
129    pub webhook_id: String,
130    /// Event topic.
131    pub topic: String,
132    /// Total attempts made.
133    pub attempts: u32,
134    /// Last error message.
135    pub last_error: String,
136    /// Unix timestamp when dead-lettered.
137    pub dead_at: u64,
138}
139
140// ── Registry ────────────────────────────────────────────────────────────
141
142/// Webhook registry — manages webhook CRUD and delivery logging.
143pub struct WebhookRegistry {
144    /// Registered webhooks by ID.
145    webhooks: HashMap<String, WebhookConfig>,
146    /// Recent delivery log (ring buffer).
147    deliveries: Vec<WebhookDelivery>,
148    /// Max delivery log entries.
149    max_deliveries: usize,
150    /// Auto-increment counter for ID generation.
151    next_id: u64,
152    /// Retry queue for failed deliveries.
153    retry_queue: Vec<RetryEntry>,
154    /// Dead letter queue for permanently failed deliveries.
155    dead_letters: Vec<DeadLetterEntry>,
156    /// Max retry attempts before dead-lettering (default 5).
157    pub max_retry_attempts: u32,
158}
159
160impl WebhookRegistry {
161    /// Create a new empty registry.
162    pub fn new() -> Self {
163        WebhookRegistry {
164            webhooks: HashMap::new(),
165            deliveries: Vec::new(),
166            max_deliveries: 500,
167            next_id: 1,
168            retry_queue: Vec::new(),
169            dead_letters: Vec::new(),
170            max_retry_attempts: 5,
171        }
172    }
173
174    /// Register a new webhook. Returns the assigned ID.
175    pub fn register(
176        &mut self,
177        name: &str,
178        url: &str,
179        events: Vec<String>,
180        secret: Option<String>,
181    ) -> String {
182        self.register_with_template(name, url, events, secret, None)
183    }
184
185    /// Register a new webhook with optional payload template. Returns the assigned ID.
186    pub fn register_with_template(
187        &mut self,
188        name: &str,
189        url: &str,
190        events: Vec<String>,
191        secret: Option<String>,
192        template: Option<String>,
193    ) -> String {
194        let id = format!("wh_{}", self.next_id);
195        self.next_id += 1;
196
197        let config = WebhookConfig {
198            id: id.clone(),
199            name: name.to_string(),
200            url: url.to_string(),
201            events,
202            secret,
203            active: true,
204            created_at: now_secs(),
205            delivery_count: 0,
206            failure_count: 0,
207            last_delivery: None,
208            template,
209        };
210
211        self.webhooks.insert(id.clone(), config);
212        id
213    }
214
215    /// Unregister a webhook by ID. Returns true if found and removed.
216    pub fn unregister(&mut self, id: &str) -> bool {
217        self.webhooks.remove(id).is_some()
218    }
219
220    /// Get a webhook by ID.
221    pub fn get(&self, id: &str) -> Option<&WebhookConfig> {
222        self.webhooks.get(id)
223    }
224
225    /// Toggle active state. Returns new state if found.
226    pub fn toggle(&mut self, id: &str) -> Option<bool> {
227        match self.webhooks.get_mut(id) {
228            Some(wh) => {
229                wh.active = !wh.active;
230                Some(wh.active)
231            }
232            None => None,
233        }
234    }
235
236    /// Get the event filters for a webhook.
237    pub fn get_filters(&self, id: &str) -> Option<&Vec<String>> {
238        self.webhooks.get(id).map(|wh| &wh.events)
239    }
240
241    /// Set the event filters for a webhook. Returns true if found.
242    pub fn set_filters(&mut self, id: &str, events: Vec<String>) -> bool {
243        match self.webhooks.get_mut(id) {
244            Some(wh) => { wh.events = events; true }
245            None => false,
246        }
247    }
248
249    /// List all webhooks as summaries (secrets masked).
250    pub fn list(&self) -> Vec<WebhookSummary> {
251        let mut result: Vec<WebhookSummary> = self.webhooks.values().map(|wh| {
252            WebhookSummary {
253                id: wh.id.clone(),
254                name: wh.name.clone(),
255                url: wh.url.clone(),
256                events: wh.events.clone(),
257                has_secret: wh.secret.is_some(),
258                active: wh.active,
259                created_at: wh.created_at,
260                delivery_count: wh.delivery_count,
261                failure_count: wh.failure_count,
262                last_delivery: wh.last_delivery,
263            }
264        }).collect();
265        result.sort_by(|a, b| a.id.cmp(&b.id));
266        result
267    }
268
269    /// Check which webhooks match a given event topic.
270    /// Returns IDs of matching active webhooks.
271    pub fn match_topic(&self, topic: &str) -> Vec<String> {
272        self.webhooks.values()
273            .filter(|wh| wh.active && topic_matches(&wh.events, topic))
274            .map(|wh| wh.id.clone())
275            .collect()
276    }
277
278    /// Dispatch an event: find matching webhooks and record pending deliveries.
279    /// Returns dispatch result with matched webhook IDs.
280    /// Actual HTTP delivery is NOT performed here (that's async/external).
281    pub fn dispatch(&mut self, topic: &str, _payload: &serde_json::Value, _source: &str) -> DispatchResult {
282        let matching_ids = self.match_topic(topic);
283
284        for id in &matching_ids {
285            if let Some(wh) = self.webhooks.get_mut(id) {
286                wh.delivery_count += 1;
287                wh.last_delivery = Some(now_secs());
288            }
289
290            // Record delivery as pending (status 0 = not yet sent)
291            let delivery = WebhookDelivery {
292                webhook_id: id.clone(),
293                topic: topic.to_string(),
294                status_code: 0,
295                success: false,
296                timestamp: now_secs(),
297                latency_ms: 0,
298                error: Some("pending".to_string()),
299                attempt: 0,
300            };
301            self.record_delivery(delivery);
302        }
303
304        DispatchResult {
305            matched: matching_ids.len(),
306            webhook_ids: matching_ids,
307        }
308    }
309
310    /// Record a delivery result (used after actual HTTP attempt).
311    pub fn record_delivery(&mut self, delivery: WebhookDelivery) {
312        // Update webhook stats
313        if !delivery.success && delivery.error.as_deref() != Some("pending") {
314            if let Some(wh) = self.webhooks.get_mut(&delivery.webhook_id) {
315                wh.failure_count += 1;
316            }
317        }
318
319        self.deliveries.push(delivery);
320
321        // Trim delivery log
322        while self.deliveries.len() > self.max_deliveries {
323            self.deliveries.remove(0);
324        }
325    }
326
327    /// Record a completed delivery (success or failure after HTTP attempt).
328    pub fn record_completed(
329        &mut self,
330        webhook_id: &str,
331        topic: &str,
332        status_code: u16,
333        latency_ms: u64,
334        error: Option<String>,
335        attempt: u32,
336    ) {
337        let success = (200..300).contains(&status_code);
338        if !success {
339            if let Some(wh) = self.webhooks.get_mut(webhook_id) {
340                wh.failure_count += 1;
341            }
342        }
343
344        let delivery = WebhookDelivery {
345            webhook_id: webhook_id.to_string(),
346            topic: topic.to_string(),
347            status_code,
348            success,
349            timestamp: now_secs(),
350            latency_ms,
351            error,
352            attempt,
353        };
354        self.deliveries.push(delivery);
355
356        while self.deliveries.len() > self.max_deliveries {
357            self.deliveries.remove(0);
358        }
359    }
360
361    /// Get recent deliveries, optionally filtered by webhook ID.
362    pub fn recent_deliveries(&self, limit: usize, webhook_id: Option<&str>) -> Vec<&WebhookDelivery> {
363        self.deliveries.iter().rev()
364            .filter(|d| match webhook_id {
365                Some(id) => d.webhook_id == id,
366                None => true,
367            })
368            .take(limit)
369            .collect()
370    }
371
372    /// Aggregate statistics.
373    pub fn stats(&self) -> WebhookStats {
374        let total_deliveries: u64 = self.webhooks.values().map(|w| w.delivery_count).sum();
375        let total_failures: u64 = self.webhooks.values().map(|w| w.failure_count).sum();
376        let recent: Vec<WebhookDelivery> = self.deliveries.iter().rev()
377            .take(10)
378            .cloned()
379            .collect();
380
381        WebhookStats {
382            total_webhooks: self.webhooks.len(),
383            active_webhooks: self.webhooks.values().filter(|w| w.active).count(),
384            total_deliveries,
385            total_failures,
386            recent_deliveries: recent,
387        }
388    }
389
390    /// Number of registered webhooks.
391    pub fn count(&self) -> usize {
392        self.webhooks.len()
393    }
394
395    /// Number of active webhooks.
396    pub fn active_count(&self) -> usize {
397        self.webhooks.values().filter(|w| w.active).count()
398    }
399
400    /// Enqueue a failed delivery for retry with exponential backoff.
401    /// Returns true if enqueued, false if max attempts exceeded (dead-lettered).
402    pub fn enqueue_retry(&mut self, webhook_id: &str, topic: &str, error: &str, attempt: u32) -> bool {
403        let now = now_secs();
404        if attempt >= self.max_retry_attempts {
405            // Dead-letter it
406            self.dead_letters.push(DeadLetterEntry {
407                webhook_id: webhook_id.to_string(),
408                topic: topic.to_string(),
409                attempts: attempt,
410                last_error: error.to_string(),
411                dead_at: now,
412            });
413            if self.dead_letters.len() > 200 {
414                self.dead_letters.remove(0);
415            }
416            return false;
417        }
418
419        // Exponential backoff: 2^attempt seconds (1, 2, 4, 8, 16, ...)
420        let backoff_secs = 1u64 << attempt;
421        self.retry_queue.push(RetryEntry {
422            webhook_id: webhook_id.to_string(),
423            topic: topic.to_string(),
424            attempt: attempt + 1,
425            max_attempts: self.max_retry_attempts,
426            next_retry_at: now + backoff_secs,
427            original_error: error.to_string(),
428            enqueued_at: now,
429        });
430        true
431    }
432
433    /// Get due retries (next_retry_at <= now). Removes them from the queue.
434    pub fn drain_due_retries(&mut self) -> Vec<RetryEntry> {
435        let now = now_secs();
436        let (due, remaining): (Vec<_>, Vec<_>) = self.retry_queue
437            .drain(..)
438            .partition(|r| r.next_retry_at <= now);
439        self.retry_queue = remaining;
440        due
441    }
442
443    /// View the retry queue (read-only).
444    pub fn retry_queue(&self) -> &[RetryEntry] {
445        &self.retry_queue
446    }
447
448    /// View the dead letter queue (read-only).
449    pub fn dead_letters(&self) -> &[DeadLetterEntry] {
450        &self.dead_letters
451    }
452
453    /// Number of entries in the retry queue.
454    pub fn retry_queue_len(&self) -> usize {
455        self.retry_queue.len()
456    }
457
458    /// Number of entries in the dead letter queue.
459    pub fn dead_letters_len(&self) -> usize {
460        self.dead_letters.len()
461    }
462
463    /// Compute the **HMAC-SHA256** signature of a payload, hex-encoded
464    /// and prefixed `sha256=` — the GitHub / Stripe webhook-signature
465    /// convention that every off-the-shelf verification library
466    /// (`hmac` in Python, `crypto.createHmac` in Node, etc.) expects.
467    ///
468    /// - `secret` — the webhook's shared secret, used verbatim as the
469    ///   HMAC key. HMAC accepts a key of any length (RFC 2104 hashes
470    ///   keys longer than the block size + zero-pads shorter ones), so
471    ///   no key-length precondition is imposed on adopters.
472    /// - `payload` — the EXACT request-body bytes the receiver will
473    ///   see; the receiver recomputes `HMAC-SHA256(secret, body)` and
474    ///   compares. Use [`Self::verify_signature`] for the receiving
475    ///   side — it does the comparison in constant time.
476    ///
477    /// The output is `"sha256="` followed by 64 lowercase hex digits
478    /// (256-bit digest). This is a real keyed MAC: without the secret
479    /// an attacker cannot forge a signature for a chosen payload.
480    pub fn compute_signature(secret: &str, payload: &[u8]) -> String {
481        use hmac::{Hmac, Mac};
482        use sha2::Sha256;
483        use std::fmt::Write as _;
484
485        // `new_from_slice` is infallible for HMAC — it accepts a key
486        // of any length per RFC 2104. The `InvalidLength` error
487        // variant exists only for fixed-key MACs; HMAC never returns
488        // it, so the `expect` is unreachable by construction.
489        let mut mac = <Hmac<Sha256>>::new_from_slice(secret.as_bytes())
490            .expect("HMAC-SHA256 accepts a key of any length (RFC 2104)");
491        mac.update(payload);
492        let digest = mac.finalize().into_bytes();
493
494        let mut out = String::with_capacity("sha256=".len() + digest.len() * 2);
495        out.push_str("sha256=");
496        for byte in digest {
497            let _ = write!(out, "{byte:02x}");
498        }
499        out
500    }
501
502    /// Verify a webhook signature against a payload, in **constant
503    /// time**.
504    ///
505    /// Recomputes `HMAC-SHA256(secret, payload)` and compares it
506    /// against `provided` — the value an inbound caller supplied
507    /// (e.g. an `X-Axon-Signature` header) — using a constant-time
508    /// equality check. The constant-time compare denies a network
509    /// attacker the timing side-channel that a byte-by-byte
510    /// `==` comparison would leak (which would let them recover the
511    /// correct signature one byte at a time).
512    ///
513    /// Returns `true` iff `provided` is exactly the correct
514    /// signature. A length mismatch returns `false` immediately —
515    /// the signature LENGTH is public (it is always
516    /// `"sha256=" + 64 hex`), so short-circuiting on it leaks
517    /// nothing secret.
518    pub fn verify_signature(secret: &str, payload: &[u8], provided: &str) -> bool {
519        use subtle::ConstantTimeEq;
520
521        let expected = Self::compute_signature(secret, payload);
522        if expected.len() != provided.len() {
523            return false;
524        }
525        expected.as_bytes().ct_eq(provided.as_bytes()).into()
526    }
527
528    /// Set a payload template for a webhook. Returns true if webhook found.
529    pub fn set_template(&mut self, id: &str, template: Option<String>) -> bool {
530        match self.webhooks.get_mut(id) {
531            Some(wh) => { wh.template = template; true }
532            None => false,
533        }
534    }
535
536    /// Get the payload template for a webhook.
537    pub fn get_template(&self, id: &str) -> Option<Option<&str>> {
538        self.webhooks.get(id).map(|wh| wh.template.as_deref())
539    }
540
541    /// Render a payload using a webhook's template (if set).
542    pub fn render_payload(&self, webhook_id: &str, topic: &str, payload: &serde_json::Value, source: &str) -> serde_json::Value {
543        match self.webhooks.get(webhook_id) {
544            Some(wh) => match &wh.template {
545                Some(tmpl) => {
546                    let rendered = render_template(tmpl, topic, payload, source, &wh.name, &wh.id);
547                    serde_json::from_str(&rendered).unwrap_or_else(|_| serde_json::json!({
548                        "rendered": rendered,
549                    }))
550                }
551                None => serde_json::json!({
552                    "topic": topic,
553                    "payload": payload,
554                    "source": source,
555                    "timestamp": now_secs(),
556                }),
557            }
558            None => serde_json::json!({
559                "topic": topic,
560                "payload": payload,
561                "source": source,
562            }),
563        }
564    }
565}
566
567// ── Helpers ──────────────────────────────────────────────────────────────
568
569fn now_secs() -> u64 {
570    SystemTime::now()
571        .duration_since(UNIX_EPOCH)
572        .unwrap_or_default()
573        .as_secs()
574}
575
576/// Render a template string with variable substitution.
577///
578/// Supported variables: {{topic}}, {{timestamp}}, {{source}}, {{payload}},
579/// {{webhook_name}}, {{webhook_id}}.
580pub fn render_template(
581    template: &str,
582    topic: &str,
583    payload: &serde_json::Value,
584    source: &str,
585    webhook_name: &str,
586    webhook_id: &str,
587) -> String {
588    let payload_str = serde_json::to_string(payload).unwrap_or_default();
589    template
590        .replace("{{topic}}", topic)
591        .replace("{{timestamp}}", &now_secs().to_string())
592        .replace("{{source}}", source)
593        .replace("{{payload}}", &payload_str)
594        .replace("{{webhook_name}}", webhook_name)
595        .replace("{{webhook_id}}", webhook_id)
596}
597
598/// Check if any event filter matches a topic.
599fn topic_matches(filters: &[String], topic: &str) -> bool {
600    filters.iter().any(|f| {
601        if f == "*" {
602            true
603        } else if let Some(prefix) = f.strip_suffix(".*") {
604            topic.starts_with(prefix) && (topic.len() == prefix.len() || topic.as_bytes().get(prefix.len()) == Some(&b'.'))
605        } else {
606            f == topic
607        }
608    })
609}
610
611// ── Tests ────────────────────────────────────────────────────────────────
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    #[test]
618    fn register_and_list() {
619        let mut reg = WebhookRegistry::new();
620        let id = reg.register("deploy-notify", "https://example.com/hook", vec!["deploy".into()], None);
621        assert_eq!(id, "wh_1");
622        assert_eq!(reg.count(), 1);
623
624        let list = reg.list();
625        assert_eq!(list.len(), 1);
626        assert_eq!(list[0].name, "deploy-notify");
627        assert_eq!(list[0].url, "https://example.com/hook");
628        assert!(!list[0].has_secret);
629        assert!(list[0].active);
630    }
631
632    #[test]
633    fn register_with_secret() {
634        let mut reg = WebhookRegistry::new();
635        reg.register("secure", "https://example.com", vec!["*".into()], Some("mysecret".into()));
636
637        let list = reg.list();
638        assert!(list[0].has_secret);
639        // Secret should not appear in summary
640        let json = serde_json::to_value(&list[0]).unwrap();
641        assert!(json.get("secret").is_none());
642    }
643
644    #[test]
645    fn unregister() {
646        let mut reg = WebhookRegistry::new();
647        let id = reg.register("temp", "https://temp.com", vec!["*".into()], None);
648        assert_eq!(reg.count(), 1);
649
650        assert!(reg.unregister(&id));
651        assert_eq!(reg.count(), 0);
652        assert!(!reg.unregister(&id)); // already removed
653    }
654
655    #[test]
656    fn toggle_active() {
657        let mut reg = WebhookRegistry::new();
658        let id = reg.register("toggler", "https://t.com", vec!["*".into()], None);
659
660        assert_eq!(reg.toggle(&id), Some(false)); // was true, now false
661        assert_eq!(reg.toggle(&id), Some(true));  // back to true
662        assert_eq!(reg.toggle("nonexistent"), None);
663    }
664
665    #[test]
666    fn topic_matching_exact() {
667        let mut reg = WebhookRegistry::new();
668        reg.register("deploy-only", "https://d.com", vec!["deploy".into()], None);
669
670        assert_eq!(reg.match_topic("deploy").len(), 1);
671        assert_eq!(reg.match_topic("deploy.success").len(), 0);
672        assert_eq!(reg.match_topic("other").len(), 0);
673    }
674
675    #[test]
676    fn topic_matching_prefix() {
677        let mut reg = WebhookRegistry::new();
678        reg.register("daemon-watcher", "https://d.com", vec!["daemon.*".into()], None);
679
680        assert_eq!(reg.match_topic("daemon.started").len(), 1);
681        assert_eq!(reg.match_topic("daemon.stopped").len(), 1);
682        assert_eq!(reg.match_topic("daemon").len(), 1); // "daemon" matches "daemon.*" (prefix includes exact)
683        assert_eq!(reg.match_topic("deploy").len(), 0);
684    }
685
686    #[test]
687    fn topic_matching_wildcard() {
688        let mut reg = WebhookRegistry::new();
689        reg.register("catch-all", "https://a.com", vec!["*".into()], None);
690
691        assert_eq!(reg.match_topic("deploy").len(), 1);
692        assert_eq!(reg.match_topic("daemon.crashed").len(), 1);
693        assert_eq!(reg.match_topic("anything").len(), 1);
694    }
695
696    #[test]
697    fn topic_matching_multiple_filters() {
698        let mut reg = WebhookRegistry::new();
699        reg.register("multi", "https://m.com", vec!["deploy".into(), "config.*".into()], None);
700
701        assert_eq!(reg.match_topic("deploy").len(), 1);
702        assert_eq!(reg.match_topic("config.updated").len(), 1);
703        assert_eq!(reg.match_topic("daemon.started").len(), 0);
704    }
705
706    #[test]
707    fn inactive_webhook_not_matched() {
708        let mut reg = WebhookRegistry::new();
709        let id = reg.register("inactive", "https://i.com", vec!["*".into()], None);
710        reg.toggle(&id); // deactivate
711
712        assert_eq!(reg.match_topic("deploy").len(), 0);
713    }
714
715    #[test]
716    fn dispatch_records_deliveries() {
717        let mut reg = WebhookRegistry::new();
718        reg.register("a", "https://a.com", vec!["deploy".into()], None);
719        reg.register("b", "https://b.com", vec!["*".into()], None);
720
721        let result = reg.dispatch("deploy", &serde_json::json!({"flow": "F1"}), "server");
722        assert_eq!(result.matched, 2);
723        assert_eq!(result.webhook_ids.len(), 2);
724
725        // Delivery count incremented
726        let list = reg.list();
727        for wh in &list {
728            assert_eq!(wh.delivery_count, 1);
729            assert!(wh.last_delivery.is_some());
730        }
731    }
732
733    #[test]
734    fn dispatch_non_matching_topic() {
735        let mut reg = WebhookRegistry::new();
736        reg.register("deploy-only", "https://d.com", vec!["deploy".into()], None);
737
738        let result = reg.dispatch("config.updated", &serde_json::json!({}), "server");
739        assert_eq!(result.matched, 0);
740        assert!(result.webhook_ids.is_empty());
741    }
742
743    #[test]
744    fn record_completed_delivery() {
745        let mut reg = WebhookRegistry::new();
746        let id = reg.register("test", "https://t.com", vec!["*".into()], None);
747
748        reg.record_completed(&id, "deploy", 200, 45, None, 0);
749        reg.record_completed(&id, "deploy", 500, 120, Some("server error".into()), 0);
750
751        let deliveries = reg.recent_deliveries(10, Some(&id));
752        assert_eq!(deliveries.len(), 2);
753        assert!(deliveries[0].success == false); // 500 is newest (reversed)
754        assert!(deliveries[1].success == true);  // 200
755
756        let stats = reg.stats();
757        assert_eq!(stats.total_failures, 1);
758    }
759
760    #[test]
761    fn recent_deliveries_filtered() {
762        let mut reg = WebhookRegistry::new();
763        let id1 = reg.register("a", "https://a.com", vec!["*".into()], None);
764        let id2 = reg.register("b", "https://b.com", vec!["*".into()], None);
765
766        reg.record_completed(&id1, "deploy", 200, 10, None, 0);
767        reg.record_completed(&id2, "config", 200, 20, None, 0);
768        reg.record_completed(&id1, "deploy", 201, 15, None, 0);
769
770        let all = reg.recent_deliveries(10, None);
771        assert_eq!(all.len(), 3);
772
773        let a_only = reg.recent_deliveries(10, Some(&id1));
774        assert_eq!(a_only.len(), 2);
775
776        let b_only = reg.recent_deliveries(10, Some(&id2));
777        assert_eq!(b_only.len(), 1);
778    }
779
780    #[test]
781    fn stats_aggregation() {
782        let mut reg = WebhookRegistry::new();
783        let id = reg.register("stats-test", "https://s.com", vec!["*".into()], None);
784
785        reg.dispatch("deploy", &serde_json::json!({}), "server");
786        reg.dispatch("config", &serde_json::json!({}), "server");
787        reg.record_completed(&id, "error", 500, 100, Some("fail".into()), 0);
788
789        let stats = reg.stats();
790        assert_eq!(stats.total_webhooks, 1);
791        assert_eq!(stats.active_webhooks, 1);
792        assert_eq!(stats.total_deliveries, 2);
793        assert_eq!(stats.total_failures, 1);
794        assert!(stats.recent_deliveries.len() >= 2);
795    }
796
797    #[test]
798    fn auto_increment_ids() {
799        let mut reg = WebhookRegistry::new();
800        let id1 = reg.register("a", "https://a.com", vec!["*".into()], None);
801        let id2 = reg.register("b", "https://b.com", vec!["*".into()], None);
802        let id3 = reg.register("c", "https://c.com", vec!["*".into()], None);
803
804        assert_eq!(id1, "wh_1");
805        assert_eq!(id2, "wh_2");
806        assert_eq!(id3, "wh_3");
807    }
808
809    #[test]
810    fn compute_signature_deterministic() {
811        let sig1 = WebhookRegistry::compute_signature("secret", b"payload");
812        let sig2 = WebhookRegistry::compute_signature("secret", b"payload");
813        assert_eq!(sig1, sig2);
814        assert!(sig1.starts_with("sha256="));
815
816        // Different secret produces a different signature.
817        let sig3 = WebhookRegistry::compute_signature("other", b"payload");
818        assert_ne!(sig1, sig3);
819
820        // Different payload produces a different signature.
821        let sig4 = WebhookRegistry::compute_signature("secret", b"payload2");
822        assert_ne!(sig1, sig4);
823    }
824
825    #[test]
826    fn compute_signature_emits_256_bit_digest() {
827        // `sha256=` + 64 lowercase hex digits = a real 256-bit HMAC
828        // digest. The pre-fix FNV-64 implementation emitted only 16
829        // hex digits (64 bits) — this pins the regression shut.
830        let sig = WebhookRegistry::compute_signature("k", b"body");
831        let hex = sig.strip_prefix("sha256=").expect("sha256= prefix");
832        assert_eq!(hex.len(), 64, "HMAC-SHA256 digest is 256 bits / 64 hex");
833        assert!(
834            hex.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
835            "digest must be lowercase hex"
836        );
837    }
838
839    #[test]
840    fn compute_signature_matches_canonical_hmac_sha256_vectors() {
841        // Known-answer tests against published HMAC-SHA256 vectors —
842        // the proof that this is a REAL HMAC, not a look-alike hash.
843
844        // Vector 1 — the widely-published Wikipedia HMAC example:
845        //   HMAC-SHA256(key="key", "The quick brown fox jumps over the lazy dog")
846        assert_eq!(
847            WebhookRegistry::compute_signature(
848                "key",
849                b"The quick brown fox jumps over the lazy dog",
850            ),
851            "sha256=f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8",
852        );
853
854        // Vector 2 — RFC 4231 §4.3 Test Case 2 (printable key + data):
855        //   HMAC-SHA256(key="Jefe", "what do ya want for nothing?")
856        assert_eq!(
857            WebhookRegistry::compute_signature("Jefe", b"what do ya want for nothing?"),
858            "sha256=5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843",
859        );
860    }
861
862    #[test]
863    fn verify_signature_accepts_correct_and_rejects_forgeries() {
864        let secret = "webhook-shared-secret";
865        let payload = br#"{"event":"deploy","ok":true}"#;
866        let good = WebhookRegistry::compute_signature(secret, payload);
867
868        // Correct (secret, payload, signature) triple verifies.
869        assert!(WebhookRegistry::verify_signature(secret, payload, &good));
870
871        // Tampered payload — signature no longer matches.
872        assert!(!WebhookRegistry::verify_signature(
873            secret,
874            br#"{"event":"deploy","ok":false}"#,
875            &good,
876        ));
877
878        // Wrong secret — cannot reproduce the signature.
879        assert!(!WebhookRegistry::verify_signature(
880            "wrong-secret",
881            payload,
882            &good,
883        ));
884
885        // Garbage / wrong-length signature strings are rejected
886        // without panicking.
887        assert!(!WebhookRegistry::verify_signature(secret, payload, ""));
888        assert!(!WebhookRegistry::verify_signature(secret, payload, "sha256=deadbeef"));
889        assert!(!WebhookRegistry::verify_signature(
890            secret,
891            payload,
892            "not-even-a-signature",
893        ));
894    }
895
896    #[test]
897    fn summary_serializes() {
898        let mut reg = WebhookRegistry::new();
899        reg.register("ser-test", "https://s.com", vec!["deploy".into(), "config.*".into()], Some("secret".into()));
900
901        let list = reg.list();
902        let json = serde_json::to_value(&list[0]).unwrap();
903        assert_eq!(json["name"], "ser-test");
904        assert_eq!(json["url"], "https://s.com");
905        assert_eq!(json["events"].as_array().unwrap().len(), 2);
906        assert_eq!(json["has_secret"], true);
907        assert_eq!(json["active"], true);
908        assert!(json.get("secret").is_none()); // not in summary
909    }
910
911    #[test]
912    fn delivery_log_trimmed() {
913        let mut reg = WebhookRegistry::new();
914        reg.max_deliveries = 5;
915        let id = reg.register("trim", "https://t.com", vec!["*".into()], None);
916
917        for i in 0..10 {
918            reg.record_completed(&id, &format!("event_{i}"), 200, 10, None, 0);
919        }
920
921        assert_eq!(reg.deliveries.len(), 5);
922        // Should keep the most recent
923        assert_eq!(reg.deliveries.last().unwrap().topic, "event_9");
924    }
925}