1use std::cell::RefCell;
31use std::collections::{BTreeMap, HashMap};
32use std::sync::Arc;
33use std::time::Duration as StdDuration;
34
35use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
36use base64::Engine;
37use serde::{Deserialize, Serialize};
38use subtle::ConstantTimeEq;
39use time::OffsetDateTime;
40use uuid::Uuid;
41
42use crate::connectors::MetricsRegistry;
43use crate::event_log::{
44 active_event_log, install_memory_for_current_thread, EventLog, LogEvent, Topic,
45};
46use crate::runtime_limits::RuntimeLimits;
47use crate::triggers::inbox::InboxIndex;
48
49pub const DEFAULT_DEDUPE_TTL_SECS: u64 = 24 * 60 * 60;
52pub const REJECTION_TOPIC: &str = "triggers.webhook_intake.rejections";
54const INTAKE_EVENT_LOG_QUEUE_DEPTH: usize = RuntimeLimits::DEFAULT.default_event_log_queue_depth;
55
56#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
59pub struct WebhookIntakeId(pub String);
60
61impl WebhookIntakeId {
62 pub fn new() -> Self {
63 Self(format!("intake_{}", Uuid::now_v7()))
64 }
65
66 pub fn as_str(&self) -> &str {
67 &self.0
68 }
69}
70
71impl Default for WebhookIntakeId {
72 fn default() -> Self {
73 Self::new()
74 }
75}
76
77#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum HmacAlgorithm {
81 #[default]
82 Sha256,
83 Sha1,
84}
85
86impl HmacAlgorithm {
87 pub fn parse(raw: &str) -> Option<Self> {
88 Self::parse_with_legacy_sha1(raw, false)
89 }
90
91 pub fn parse_with_legacy_sha1(raw: &str, allow_legacy_sha1: bool) -> Option<Self> {
92 match raw.trim().to_ascii_lowercase().as_str() {
93 "" | "sha256" | "hmac-sha256" => Some(Self::Sha256),
94 "sha1" | "hmac-sha1" if allow_legacy_sha1 => Some(Self::Sha1),
95 _ => None,
96 }
97 }
98
99 pub fn is_legacy_sha1_alias(raw: &str) -> bool {
100 matches!(
101 raw.trim().to_ascii_lowercase().as_str(),
102 "sha1" | "hmac-sha1"
103 )
104 }
105
106 pub fn as_str(&self) -> &'static str {
107 match self {
108 Self::Sha256 => "sha256",
109 Self::Sha1 => "sha1",
110 }
111 }
112}
113
114#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum SignatureEncoding {
118 #[default]
119 Hex,
120 Base64,
121}
122
123impl SignatureEncoding {
124 pub fn parse(raw: &str) -> Option<Self> {
125 match raw.trim().to_ascii_lowercase().as_str() {
126 "" | "hex" | "base16" => Some(Self::Hex),
127 "base64" | "b64" => Some(Self::Base64),
128 _ => None,
129 }
130 }
131
132 pub fn as_str(&self) -> &'static str {
133 match self {
134 Self::Hex => "hex",
135 Self::Base64 => "base64",
136 }
137 }
138}
139
140#[derive(Clone, Debug)]
142pub struct WebhookIntakeConfig {
143 pub id: Option<String>,
144 pub path: Option<String>,
145 pub signature_header: String,
146 pub signature_prefix: Option<String>,
147 pub signature_encoding: SignatureEncoding,
148 pub algorithm: HmacAlgorithm,
149 pub allow_legacy_sha1: bool,
150 pub secret: Vec<u8>,
151 pub delivery_id_header: String,
152 pub topic: String,
153 pub dedupe_ttl: StdDuration,
154}
155
156impl WebhookIntakeConfig {
157 pub fn default_prefix(algorithm: HmacAlgorithm) -> Option<String> {
161 Some(format!("{}=", algorithm.as_str()))
162 }
163}
164
165#[derive(Clone, Debug, PartialEq, Eq)]
167pub enum WebhookIntakeStatus {
168 Accepted,
169 Duplicate,
170 Rejected,
171}
172
173impl WebhookIntakeStatus {
174 pub fn as_str(&self) -> &'static str {
175 match self {
176 Self::Accepted => "accepted",
177 Self::Duplicate => "duplicate",
178 Self::Rejected => "rejected",
179 }
180 }
181}
182
183#[derive(Clone, Debug, Serialize, Deserialize)]
185pub struct WebhookIntakeOutcome {
186 pub status: String,
187 pub intake_id: String,
188 pub topic: String,
189 pub delivery_id: Option<String>,
190 pub topic_event_id: Option<u64>,
191 pub reason: Option<String>,
192 pub received_at: String,
193}
194
195#[derive(Clone, Debug, Serialize, Deserialize)]
197pub struct WebhookIntakeSnapshot {
198 pub id: String,
199 pub path: Option<String>,
200 pub topic: String,
201 pub signature_header: String,
202 pub signature_prefix: Option<String>,
203 pub signature_encoding: String,
204 pub algorithm: String,
205 pub allow_legacy_sha1: bool,
206 pub delivery_id_header: String,
207 pub dedupe_ttl_seconds: u64,
208}
209
210#[derive(Clone, Debug, Default)]
212pub struct WebhookIntakeRequest {
213 pub headers: BTreeMap<String, String>,
214 pub body: Vec<u8>,
215 pub path: Option<String>,
216 pub received_at: Option<OffsetDateTime>,
217}
218
219#[derive(Debug)]
220pub enum WebhookIntakeError {
221 Config(String),
222 UnknownIntake(String),
223 Internal(String),
224}
225
226impl std::fmt::Display for WebhookIntakeError {
227 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 match self {
229 Self::Config(detail) => write!(f, "intake config: {detail}"),
230 Self::UnknownIntake(id) => write!(f, "intake `{id}` is not registered"),
231 Self::Internal(detail) => write!(f, "intake substrate: {detail}"),
232 }
233 }
234}
235
236impl std::error::Error for WebhookIntakeError {}
237
238#[derive(Clone)]
239struct RegisteredIntake {
240 snapshot: WebhookIntakeSnapshot,
241 config: WebhookIntakeConfig,
242}
243
244#[derive(Default)]
245struct WebhookIntakeRegistry {
246 by_id: HashMap<String, RegisteredIntake>,
247 by_path: HashMap<String, String>,
248}
249
250thread_local! {
251 static REGISTRY: RefCell<WebhookIntakeRegistry> =
252 RefCell::new(WebhookIntakeRegistry::default());
253}
254
255fn with_registry<R>(f: impl FnOnce(&WebhookIntakeRegistry) -> R) -> R {
256 REGISTRY.with(|slot| f(&slot.borrow()))
257}
258
259fn with_registry_mut<R>(f: impl FnOnce(&mut WebhookIntakeRegistry) -> R) -> R {
260 REGISTRY.with(|slot| f(&mut slot.borrow_mut()))
261}
262
263pub fn clear_webhook_intake_state() {
265 with_registry_mut(|state| {
266 state.by_id.clear();
267 state.by_path.clear();
268 });
269 reset_cached_inbox();
270}
271
272pub fn snapshot_webhook_intakes() -> Vec<WebhookIntakeSnapshot> {
274 with_registry(|state| {
275 let mut out: Vec<_> = state
276 .by_id
277 .values()
278 .map(|entry| entry.snapshot.clone())
279 .collect();
280 out.sort_by(|left, right| left.id.cmp(&right.id));
281 out
282 })
283}
284
285pub fn intake_for_path(path: &str) -> Option<String> {
287 with_registry(|state| state.by_path.get(path).cloned())
288}
289
290pub fn register_webhook_intake(
292 config: WebhookIntakeConfig,
293) -> Result<WebhookIntakeSnapshot, WebhookIntakeError> {
294 if config.signature_header.trim().is_empty() {
295 return Err(WebhookIntakeError::Config(
296 "signature_header cannot be empty".to_string(),
297 ));
298 }
299 if config.delivery_id_header.trim().is_empty() {
300 return Err(WebhookIntakeError::Config(
301 "delivery_id_header cannot be empty".to_string(),
302 ));
303 }
304 if config.topic.trim().is_empty() {
305 return Err(WebhookIntakeError::Config(
306 "topic cannot be empty".to_string(),
307 ));
308 }
309 if config.secret.is_empty() {
310 return Err(WebhookIntakeError::Config(
311 "secret cannot be empty".to_string(),
312 ));
313 }
314 if config.algorithm == HmacAlgorithm::Sha1 && !config.allow_legacy_sha1 {
315 return Err(WebhookIntakeError::Config(
316 "algorithm `sha1` is legacy; set `allow_legacy_sha1: true` to verify SHA-1 HMAC signatures for an existing provider".to_string(),
317 ));
318 }
319 Topic::new(config.topic.clone())
320 .map_err(|error| WebhookIntakeError::Config(format!("invalid topic: {error}")))?;
321
322 let id = config
323 .id
324 .clone()
325 .filter(|raw| !raw.trim().is_empty())
326 .unwrap_or_else(|| WebhookIntakeId::new().0);
327
328 with_registry_mut(|state| {
329 if state.by_id.contains_key(&id) {
330 return Err(WebhookIntakeError::Config(format!(
331 "intake `{id}` is already registered"
332 )));
333 }
334 if let Some(path) = config.path.as_ref() {
335 if state.by_path.contains_key(path) {
336 return Err(WebhookIntakeError::Config(format!(
337 "path `{path}` is already bound to intake `{}`",
338 state.by_path[path]
339 )));
340 }
341 }
342
343 let snapshot = WebhookIntakeSnapshot {
344 id: id.clone(),
345 path: config.path.clone(),
346 topic: config.topic.clone(),
347 signature_header: normalize_header(&config.signature_header),
348 signature_prefix: config.signature_prefix.clone(),
349 signature_encoding: config.signature_encoding.as_str().to_string(),
350 algorithm: config.algorithm.as_str().to_string(),
351 allow_legacy_sha1: config.allow_legacy_sha1,
352 delivery_id_header: normalize_header(&config.delivery_id_header),
353 dedupe_ttl_seconds: config.dedupe_ttl.as_secs(),
354 };
355
356 let mut stored = config;
357 stored.id = Some(id.clone());
358 stored.signature_header = normalize_header(&stored.signature_header);
359 stored.delivery_id_header = normalize_header(&stored.delivery_id_header);
360
361 if let Some(path) = stored.path.as_ref() {
362 state.by_path.insert(path.clone(), id.clone());
363 }
364 state.by_id.insert(
365 id,
366 RegisteredIntake {
367 snapshot: snapshot.clone(),
368 config: stored,
369 },
370 );
371 Ok(snapshot)
372 })
373}
374
375pub fn deregister_webhook_intake(intake_id: &str) -> bool {
377 with_registry_mut(|state| {
378 let Some(entry) = state.by_id.remove(intake_id) else {
379 return false;
380 };
381 if let Some(path) = entry.snapshot.path {
382 state.by_path.remove(&path);
383 }
384 true
385 })
386}
387
388pub async fn feed_webhook_intake(
395 intake_id: &str,
396 request: WebhookIntakeRequest,
397) -> Result<WebhookIntakeOutcome, WebhookIntakeError> {
398 let entry = with_registry(|state| state.by_id.get(intake_id).cloned())
399 .ok_or_else(|| WebhookIntakeError::UnknownIntake(intake_id.to_string()))?;
400 let received_at = request.received_at.unwrap_or_else(OffsetDateTime::now_utc);
401 let received_at_str = format_rfc3339(received_at);
402 let log = ensure_event_log();
403 let topic = Topic::new(entry.snapshot.topic.clone())
404 .map_err(|error| WebhookIntakeError::Internal(format!("invalid topic: {error}")))?;
405
406 if let Some(expected_path) = entry.config.path.as_deref() {
407 if let Some(actual_path) = request.path.as_deref() {
408 if actual_path != expected_path {
409 let reason =
410 format!("path mismatch: expected `{expected_path}`, got `{actual_path}`");
411 emit_rejection(&log, &entry.snapshot, &reason, &received_at_str).await;
412 return Ok(WebhookIntakeOutcome {
413 status: WebhookIntakeStatus::Rejected.as_str().to_string(),
414 intake_id: entry.snapshot.id.clone(),
415 topic: entry.snapshot.topic.clone(),
416 delivery_id: None,
417 topic_event_id: None,
418 reason: Some(reason),
419 received_at: received_at_str,
420 });
421 }
422 }
423 }
424
425 let signature = match find_header(&request.headers, &entry.config.signature_header) {
426 Some(value) => value,
427 None => {
428 let reason = format!(
429 "missing signature header `{}`",
430 entry.config.signature_header
431 );
432 emit_rejection(&log, &entry.snapshot, &reason, &received_at_str).await;
433 return Ok(WebhookIntakeOutcome {
434 status: WebhookIntakeStatus::Rejected.as_str().to_string(),
435 intake_id: entry.snapshot.id.clone(),
436 topic: entry.snapshot.topic.clone(),
437 delivery_id: None,
438 topic_event_id: None,
439 reason: Some(reason),
440 received_at: received_at_str,
441 });
442 }
443 };
444
445 if let Err(reason) = verify_signature(&entry.config, signature.as_str(), &request.body) {
446 emit_rejection(&log, &entry.snapshot, &reason, &received_at_str).await;
447 return Ok(WebhookIntakeOutcome {
448 status: WebhookIntakeStatus::Rejected.as_str().to_string(),
449 intake_id: entry.snapshot.id.clone(),
450 topic: entry.snapshot.topic.clone(),
451 delivery_id: None,
452 topic_event_id: None,
453 reason: Some(reason),
454 received_at: received_at_str,
455 });
456 }
457
458 let delivery_id = match find_header(&request.headers, &entry.config.delivery_id_header) {
459 Some(value) if !value.trim().is_empty() => value,
460 _ => {
461 let reason = format!(
462 "missing delivery id header `{}`",
463 entry.config.delivery_id_header
464 );
465 emit_rejection(&log, &entry.snapshot, &reason, &received_at_str).await;
466 return Ok(WebhookIntakeOutcome {
467 status: WebhookIntakeStatus::Rejected.as_str().to_string(),
468 intake_id: entry.snapshot.id.clone(),
469 topic: entry.snapshot.topic.clone(),
470 delivery_id: None,
471 topic_event_id: None,
472 reason: Some(reason),
473 received_at: received_at_str,
474 });
475 }
476 };
477
478 let inbox = ensure_inbox().await?;
479 let claim_key = format!("intake:{}", entry.snapshot.id);
480 let claimed = inbox
481 .insert_if_new(&claim_key, &delivery_id, entry.config.dedupe_ttl)
482 .await
483 .map_err(|error| WebhookIntakeError::Internal(format!("inbox claim: {error}")))?;
484 if !claimed {
485 return Ok(WebhookIntakeOutcome {
486 status: WebhookIntakeStatus::Duplicate.as_str().to_string(),
487 intake_id: entry.snapshot.id.clone(),
488 topic: entry.snapshot.topic.clone(),
489 delivery_id: Some(delivery_id),
490 topic_event_id: None,
491 reason: None,
492 received_at: received_at_str,
493 });
494 }
495
496 let payload = serde_json::json!({
497 "intake_id": entry.snapshot.id,
498 "delivery_id": delivery_id,
499 "received_at": received_at_str,
500 "headers": request.headers,
501 "body_b64": BASE64_STANDARD.encode(&request.body),
502 "body_text": std::str::from_utf8(&request.body).ok().map(str::to_string),
503 "path": entry.config.path,
504 "signature_header": entry.config.signature_header,
505 "delivery_id_header": entry.config.delivery_id_header,
506 "algorithm": entry.config.algorithm.as_str(),
507 });
508 let mut headers_meta = BTreeMap::new();
509 headers_meta.insert("intake_id".to_string(), entry.snapshot.id.clone());
510 headers_meta.insert("delivery_id".to_string(), delivery_id.clone());
511 let event_id = log
512 .append(
513 &topic,
514 LogEvent::new("webhook_delivery", payload).with_headers(headers_meta),
515 )
516 .await
517 .map_err(|error| WebhookIntakeError::Internal(format!("topic append: {error}")))?;
518
519 Ok(WebhookIntakeOutcome {
520 status: WebhookIntakeStatus::Accepted.as_str().to_string(),
521 intake_id: entry.snapshot.id.clone(),
522 topic: entry.snapshot.topic.clone(),
523 delivery_id: Some(delivery_id),
524 topic_event_id: Some(event_id),
525 reason: None,
526 received_at: received_at_str,
527 })
528}
529
530pub async fn recent_webhook_deliveries(
533 intake_id: &str,
534 limit: usize,
535) -> Result<Vec<serde_json::Value>, WebhookIntakeError> {
536 let topic_name = with_registry(|state| {
537 state
538 .by_id
539 .get(intake_id)
540 .map(|entry| entry.snapshot.topic.clone())
541 })
542 .ok_or_else(|| WebhookIntakeError::UnknownIntake(intake_id.to_string()))?;
543 let log = ensure_event_log();
544 let topic = Topic::new(topic_name)
545 .map_err(|error| WebhookIntakeError::Internal(format!("invalid topic: {error}")))?;
546 let events = log
547 .read_range(&topic, None, usize::MAX)
548 .await
549 .map_err(|error| WebhookIntakeError::Internal(format!("topic read: {error}")))?;
550 let mut payloads: Vec<serde_json::Value> = events
551 .into_iter()
552 .filter(|(_, event)| {
553 event.kind == "webhook_delivery"
554 && event
555 .headers
556 .get("intake_id")
557 .map(|value| value == intake_id)
558 .unwrap_or(true)
559 })
560 .map(|(_, event)| event.payload)
561 .collect();
562 if payloads.len() > limit {
563 let drop = payloads.len() - limit;
564 payloads.drain(0..drop);
565 }
566 Ok(payloads)
567}
568
569fn verify_signature(
570 config: &WebhookIntakeConfig,
571 raw_signature: &str,
572 body: &[u8],
573) -> Result<(), String> {
574 let stripped = strip_prefix(raw_signature, config.signature_prefix.as_deref())?;
575 let provided = decode_signature(stripped, config.signature_encoding)?;
576 let expected = compute_hmac(config.algorithm, &config.secret, body);
577 if provided.len() != expected.len() {
578 return Err(format!(
579 "signature length mismatch (expected {} bytes, got {})",
580 expected.len(),
581 provided.len()
582 ));
583 }
584 if expected.ct_eq(&provided).into() {
585 Ok(())
586 } else {
587 Err("signature mismatch".to_string())
588 }
589}
590
591fn strip_prefix<'a>(raw: &'a str, prefix: Option<&str>) -> Result<&'a str, String> {
592 let trimmed = raw.trim();
593 match prefix {
594 Some(expected) if !expected.is_empty() => trimmed
595 .strip_prefix(expected)
596 .ok_or_else(|| format!("signature missing expected prefix `{expected}`")),
597 _ => Ok(trimmed),
598 }
599}
600
601fn decode_signature(raw: &str, encoding: SignatureEncoding) -> Result<Vec<u8>, String> {
602 match encoding {
603 SignatureEncoding::Hex => hex::decode(raw).map_err(|error| format!("invalid hex: {error}")),
604 SignatureEncoding::Base64 => BASE64_STANDARD
605 .decode(raw)
606 .map_err(|error| format!("invalid base64: {error}")),
607 }
608}
609
610fn compute_hmac(algorithm: HmacAlgorithm, secret: &[u8], data: &[u8]) -> Vec<u8> {
611 match algorithm {
612 HmacAlgorithm::Sha256 => crate::connectors::hmac::hmac_sha256(secret, data),
613 HmacAlgorithm::Sha1 => crate::connectors::hmac::hmac_sha1(secret, data),
614 }
615}
616
617fn find_header(headers: &BTreeMap<String, String>, name: &str) -> Option<String> {
618 let lower = name.to_ascii_lowercase();
619 headers
620 .iter()
621 .find(|(key, _)| key.to_ascii_lowercase() == lower)
622 .map(|(_, value)| value.clone())
623}
624
625fn normalize_header(name: &str) -> String {
626 name.trim().to_ascii_lowercase()
627}
628
629fn format_rfc3339(value: OffsetDateTime) -> String {
630 harn_clock::format_rfc3339(value)
631}
632
633fn ensure_event_log() -> Arc<crate::event_log::AnyEventLog> {
634 active_event_log()
635 .unwrap_or_else(|| install_memory_for_current_thread(INTAKE_EVENT_LOG_QUEUE_DEPTH))
636}
637
638thread_local! {
639 static CACHED_INBOX: RefCell<Option<(Arc<crate::event_log::AnyEventLog>, Arc<InboxIndex>)>> =
640 const { RefCell::new(None) };
641}
642
643async fn ensure_inbox() -> Result<Arc<InboxIndex>, WebhookIntakeError> {
644 let log = ensure_event_log();
645 if let Some(existing) = CACHED_INBOX.with(|slot| {
646 slot.borrow()
647 .as_ref()
648 .filter(|(cached_log, _)| Arc::ptr_eq(cached_log, &log))
649 .map(|(_, inbox)| inbox.clone())
650 }) {
651 return Ok(existing);
652 }
653 let metrics = Arc::new(MetricsRegistry::default());
654 let inbox = Arc::new(
655 InboxIndex::new(log.clone(), metrics)
656 .await
657 .map_err(|error| WebhookIntakeError::Internal(format!("inbox init: {error}")))?,
658 );
659 CACHED_INBOX.with(|slot| {
660 *slot.borrow_mut() = Some((log, inbox.clone()));
661 });
662 Ok(inbox)
663}
664
665fn reset_cached_inbox() {
666 CACHED_INBOX.with(|slot| *slot.borrow_mut() = None);
667}
668
669async fn emit_rejection(
670 log: &Arc<crate::event_log::AnyEventLog>,
671 snapshot: &WebhookIntakeSnapshot,
672 reason: &str,
673 received_at: &str,
674) {
675 let topic = match Topic::new(REJECTION_TOPIC) {
676 Ok(topic) => topic,
677 Err(_) => return,
678 };
679 let mut headers = BTreeMap::new();
680 headers.insert("intake_id".to_string(), snapshot.id.clone());
681 let payload = serde_json::json!({
682 "intake_id": snapshot.id,
683 "topic": snapshot.topic,
684 "path": snapshot.path,
685 "reason": reason,
686 "received_at": received_at,
687 });
688 let _ = log
689 .append(
690 &topic,
691 LogEvent::new("webhook_intake_rejected", payload).with_headers(headers),
692 )
693 .await;
694}
695
696pub fn build_request(
700 headers: BTreeMap<String, String>,
701 body: Vec<u8>,
702 path: Option<String>,
703 received_at: Option<OffsetDateTime>,
704) -> WebhookIntakeRequest {
705 WebhookIntakeRequest {
706 headers,
707 body,
708 path,
709 received_at,
710 }
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716 use crate::event_log::{install_default_for_base_dir, AnyEventLog, FileEventLog};
717
718 fn make_config(secret: &[u8]) -> WebhookIntakeConfig {
719 WebhookIntakeConfig {
720 id: None,
721 path: None,
722 signature_header: "x-test-signature".to_string(),
723 signature_prefix: Some("sha256=".to_string()),
724 signature_encoding: SignatureEncoding::Hex,
725 algorithm: HmacAlgorithm::Sha256,
726 allow_legacy_sha1: false,
727 secret: secret.to_vec(),
728 delivery_id_header: "x-test-delivery".to_string(),
729 topic: "tests.webhook_intake".to_string(),
730 dedupe_ttl: StdDuration::from_mins(1),
731 }
732 }
733
734 fn signed_headers(
735 config: &WebhookIntakeConfig,
736 body: &[u8],
737 delivery_id: &str,
738 ) -> BTreeMap<String, String> {
739 let digest = compute_hmac(config.algorithm, &config.secret, body);
740 let signature = match config.signature_encoding {
741 SignatureEncoding::Hex => hex::encode(&digest),
742 SignatureEncoding::Base64 => BASE64_STANDARD.encode(&digest),
743 };
744 let prefix = config.signature_prefix.clone().unwrap_or_default();
745 let mut headers = BTreeMap::new();
746 headers.insert(
747 config.signature_header.clone(),
748 format!("{prefix}{signature}"),
749 );
750 headers.insert(config.delivery_id_header.clone(), delivery_id.to_string());
751 headers
752 }
753
754 async fn reset() {
755 clear_webhook_intake_state();
756 crate::event_log::reset_active_event_log();
757 }
758
759 #[tokio::test(flavor = "current_thread")]
760 async fn round_trip_accepts_then_dedupes() {
761 reset().await;
762 let body = br#"{"hello":"world"}"#.to_vec();
763 let config = make_config(b"super-secret");
764 let snapshot = register_webhook_intake(config.clone()).expect("register");
765
766 let headers = signed_headers(&config, &body, "delivery-1");
767 let outcome = feed_webhook_intake(
768 snapshot.id.as_str(),
769 build_request(headers.clone(), body.clone(), None, None),
770 )
771 .await
772 .expect("feed");
773 assert_eq!(outcome.status, "accepted");
774 assert_eq!(outcome.delivery_id.as_deref(), Some("delivery-1"));
775 assert!(outcome.topic_event_id.is_some());
776
777 let dup = feed_webhook_intake(
778 snapshot.id.as_str(),
779 build_request(headers, body, None, None),
780 )
781 .await
782 .expect("feed dup");
783 assert_eq!(dup.status, "duplicate");
784 }
785
786 #[tokio::test(flavor = "current_thread")]
787 async fn rejects_bad_signature() {
788 reset().await;
789 let body = b"payload".to_vec();
790 let config = make_config(b"correct-secret");
791 let snapshot = register_webhook_intake(config.clone()).expect("register");
792
793 let mut headers = signed_headers(&config, &body, "delivery-1");
794 headers.insert(
795 config.signature_header.clone(),
796 "sha256=deadbeef".to_string(),
797 );
798 let outcome = feed_webhook_intake(
799 snapshot.id.as_str(),
800 build_request(headers, body, None, None),
801 )
802 .await
803 .expect("feed");
804 assert_eq!(outcome.status, "rejected");
805 assert!(outcome
806 .reason
807 .as_deref()
808 .unwrap_or("")
809 .contains("signature"));
810 }
811
812 #[tokio::test(flavor = "current_thread")]
813 async fn isolates_two_intakes_on_different_paths() {
814 reset().await;
815 let body = b"shared-body".to_vec();
816 let mut config_a = make_config(b"secret-a");
817 config_a.path = Some("/hooks/a".to_string());
818 config_a.topic = "tests.intake_a".to_string();
819 config_a.delivery_id_header = "x-a-delivery".to_string();
820
821 let mut config_b = make_config(b"secret-b");
822 config_b.path = Some("/hooks/b".to_string());
823 config_b.topic = "tests.intake_b".to_string();
824 config_b.delivery_id_header = "x-b-delivery".to_string();
825
826 let snap_a = register_webhook_intake(config_a.clone()).expect("register a");
827 let snap_b = register_webhook_intake(config_b.clone()).expect("register b");
828
829 let headers_a = signed_headers(&config_a, &body, "shared-delivery");
831 let outcome_a = feed_webhook_intake(
832 snap_a.id.as_str(),
833 build_request(headers_a, body.clone(), Some("/hooks/a".to_string()), None),
834 )
835 .await
836 .expect("feed a");
837 assert_eq!(outcome_a.status, "accepted");
838
839 let headers_b = signed_headers(&config_b, &body, "shared-delivery");
840 let outcome_b = feed_webhook_intake(
841 snap_b.id.as_str(),
842 build_request(headers_b, body, Some("/hooks/b".to_string()), None),
843 )
844 .await
845 .expect("feed b");
846 assert_eq!(outcome_b.status, "accepted");
847
848 let body = b"more".to_vec();
850 let headers = signed_headers(&config_a, &body, "another-delivery");
851 let outcome = feed_webhook_intake(
852 snap_a.id.as_str(),
853 build_request(headers, body, Some("/hooks/wrong".to_string()), None),
854 )
855 .await
856 .expect("feed mismatch");
857 assert_eq!(outcome.status, "rejected");
858 assert!(outcome.reason.unwrap().contains("path mismatch"));
859 }
860
861 #[tokio::test(flavor = "current_thread")]
862 async fn supports_sha1_legacy() {
863 reset().await;
864 let body = b"legacy".to_vec();
865 let mut config = make_config(b"legacy-secret");
866 config.algorithm = HmacAlgorithm::Sha1;
867 config.allow_legacy_sha1 = true;
868 config.signature_prefix = Some("sha1=".to_string());
869 let snapshot = register_webhook_intake(config.clone()).expect("register");
870
871 let headers = signed_headers(&config, &body, "legacy-delivery");
872 let outcome = feed_webhook_intake(
873 snapshot.id.as_str(),
874 build_request(headers, body, None, None),
875 )
876 .await
877 .expect("feed");
878 assert_eq!(outcome.status, "accepted");
879 }
880
881 #[test]
882 fn hmac_algorithm_parser_gates_sha1() {
883 assert_eq!(HmacAlgorithm::parse("sha256"), Some(HmacAlgorithm::Sha256));
884 assert_eq!(HmacAlgorithm::parse("sha1"), None);
885 assert_eq!(
886 HmacAlgorithm::parse_with_legacy_sha1("sha1", true),
887 Some(HmacAlgorithm::Sha1)
888 );
889 }
890
891 #[tokio::test(flavor = "current_thread")]
892 async fn rejects_sha1_without_legacy_opt_in() {
893 reset().await;
894 let mut config = make_config(b"legacy-secret");
895 config.algorithm = HmacAlgorithm::Sha1;
896 config.signature_prefix = Some("sha1=".to_string());
897
898 let error = register_webhook_intake(config).expect_err("sha1 requires opt-in");
899 assert!(error.to_string().contains("set `allow_legacy_sha1: true`"));
900 }
901
902 #[tokio::test(flavor = "current_thread")]
903 async fn dedupe_survives_process_restart() {
904 clear_webhook_intake_state();
905 crate::event_log::reset_active_event_log();
906 let tmp = tempfile::tempdir().expect("tempdir");
907 let log = Arc::new(AnyEventLog::File(
909 FileEventLog::open(tmp.path().to_path_buf(), 32).expect("file log"),
910 ));
911 crate::event_log::install_active_event_log(log.clone());
912
913 let body = br#"{"hello":"durable"}"#.to_vec();
914 let mut config = make_config(b"durable-secret");
915 config.id = Some("durable-intake".to_string());
918 let snapshot = register_webhook_intake(config.clone()).expect("register");
919 let headers = signed_headers(&config, &body, "durable-1");
920
921 let first = feed_webhook_intake(
922 snapshot.id.as_str(),
923 build_request(headers.clone(), body.clone(), None, None),
924 )
925 .await
926 .expect("first feed");
927 assert_eq!(first.status, "accepted");
928
929 clear_webhook_intake_state();
933 crate::event_log::reset_active_event_log();
934 let log = Arc::new(AnyEventLog::File(
935 FileEventLog::open(tmp.path().to_path_buf(), 32).expect("file log"),
936 ));
937 crate::event_log::install_active_event_log(log);
938 let snapshot = register_webhook_intake(config.clone()).expect("re-register");
939
940 let second = feed_webhook_intake(
941 snapshot.id.as_str(),
942 build_request(headers, body, None, None),
943 )
944 .await
945 .expect("second feed");
946 assert_eq!(second.status, "duplicate");
947
948 let _log = install_default_for_base_dir(tmp.path()).expect("install default");
951 }
952}