1use std::collections::HashMap;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct WebhookConfig {
24 pub id: String,
26 pub name: String,
28 pub url: String,
30 pub events: Vec<String>,
32 #[serde(skip_serializing)]
34 pub secret: Option<String>,
35 pub active: bool,
37 pub created_at: u64,
39 pub delivery_count: u64,
41 pub failure_count: u64,
43 pub last_delivery: Option<u64>,
45 #[serde(skip_serializing_if = "Option::is_none")]
48 pub template: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize)]
53pub struct WebhookDelivery {
54 pub webhook_id: String,
56 pub topic: String,
58 pub status_code: u16,
60 pub success: bool,
62 pub timestamp: u64,
64 pub latency_ms: u64,
66 pub error: Option<String>,
68 pub attempt: u32,
70}
71
72#[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#[derive(Debug, Clone, Serialize)]
89pub struct DispatchResult {
90 pub matched: usize,
92 pub webhook_ids: Vec<String>,
94}
95
96#[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#[derive(Debug, Clone, Serialize)]
108pub struct RetryEntry {
109 pub webhook_id: String,
111 pub topic: String,
113 pub attempt: u32,
115 pub max_attempts: u32,
117 pub next_retry_at: u64,
119 pub original_error: String,
121 pub enqueued_at: u64,
123}
124
125#[derive(Debug, Clone, Serialize)]
127pub struct DeadLetterEntry {
128 pub webhook_id: String,
130 pub topic: String,
132 pub attempts: u32,
134 pub last_error: String,
136 pub dead_at: u64,
138}
139
140pub struct WebhookRegistry {
144 webhooks: HashMap<String, WebhookConfig>,
146 deliveries: Vec<WebhookDelivery>,
148 max_deliveries: usize,
150 next_id: u64,
152 retry_queue: Vec<RetryEntry>,
154 dead_letters: Vec<DeadLetterEntry>,
156 pub max_retry_attempts: u32,
158}
159
160impl WebhookRegistry {
161 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 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 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 pub fn unregister(&mut self, id: &str) -> bool {
217 self.webhooks.remove(id).is_some()
218 }
219
220 pub fn get(&self, id: &str) -> Option<&WebhookConfig> {
222 self.webhooks.get(id)
223 }
224
225 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 pub fn get_filters(&self, id: &str) -> Option<&Vec<String>> {
238 self.webhooks.get(id).map(|wh| &wh.events)
239 }
240
241 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 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 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 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 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 pub fn record_delivery(&mut self, delivery: WebhookDelivery) {
312 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 while self.deliveries.len() > self.max_deliveries {
323 self.deliveries.remove(0);
324 }
325 }
326
327 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 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 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 pub fn count(&self) -> usize {
392 self.webhooks.len()
393 }
394
395 pub fn active_count(&self) -> usize {
397 self.webhooks.values().filter(|w| w.active).count()
398 }
399
400 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 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 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 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 pub fn retry_queue(&self) -> &[RetryEntry] {
445 &self.retry_queue
446 }
447
448 pub fn dead_letters(&self) -> &[DeadLetterEntry] {
450 &self.dead_letters
451 }
452
453 pub fn retry_queue_len(&self) -> usize {
455 self.retry_queue.len()
456 }
457
458 pub fn dead_letters_len(&self) -> usize {
460 self.dead_letters.len()
461 }
462
463 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 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 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 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 pub fn get_template(&self, id: &str) -> Option<Option<&str>> {
538 self.webhooks.get(id).map(|wh| wh.template.as_deref())
539 }
540
541 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
567fn now_secs() -> u64 {
570 SystemTime::now()
571 .duration_since(UNIX_EPOCH)
572 .unwrap_or_default()
573 .as_secs()
574}
575
576pub 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
598fn 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#[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 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)); }
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)); assert_eq!(reg.toggle(&id), Some(true)); 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); 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); 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 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); assert!(deliveries[1].success == true); 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 let sig3 = WebhookRegistry::compute_signature("other", b"payload");
818 assert_ne!(sig1, sig3);
819
820 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 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 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 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 assert!(WebhookRegistry::verify_signature(secret, payload, &good));
870
871 assert!(!WebhookRegistry::verify_signature(
873 secret,
874 br#"{"event":"deploy","ok":false}"#,
875 &good,
876 ));
877
878 assert!(!WebhookRegistry::verify_signature(
880 "wrong-secret",
881 payload,
882 &good,
883 ));
884
885 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()); }
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 assert_eq!(reg.deliveries.last().unwrap().topic, "event_9");
924 }
925}