Skip to main content

chronix_security/audit/
webhook.rs

1//! Webhook audit sink — forwards audit events via HTTP POST for SIEM integration.
2//!
3//! Events are serialized to JSON and sent to a configurable endpoint URL.
4//! Delivery is performed on a background thread via a bounded channel to
5//! avoid blocking the hot path.  Failed deliveries are retried with
6//! exponential backoff.
7//!
8//! # Delivery Guarantees
9//!
10//! Two modes are available:
11//!
12//! - **`BestEffort`** (default): when the in-memory channel is full, the
13//!   event is dropped and a counter is incremented.  Non-blocking,
14//!   suitable for most observability use cases.
15//!
16//! - **`AtLeastOnce`**: when the channel is full, events overflow to a
17//!   disk-backed append-only journal.  The background worker drains the
18//!   overflow file before consuming from the channel, ensuring no event
19//!   is lost even under sustained back-pressure.  Suitable for
20//!   compliance-critical deployments (SOX, HIPAA).
21//!
22//! # Formats
23//!
24//! - **JSON** (default): raw `AuditEvent` serialized as JSON
25//! - **CEF**: Common Event Format — widely supported by Splunk, ArcSight, QRadar
26
27use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
28use std::sync::mpsc;
29use std::sync::Arc;
30use std::thread;
31use std::time::Duration;
32
33use crate::audit::error::{AuditError, Result};
34use crate::audit::model::AuditEvent;
35
36/// Output format for webhook events.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum WebhookFormat {
39    /// JSON: raw `AuditEvent` as JSON object.
40    Json,
41    /// CEF: Common Event Format (RFC-like), widely used by enterprise SIEMs.
42    Cef,
43}
44
45/// Delivery guarantee mode for the webhook sink.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum DeliveryGuarantee {
48    /// Drop events when the channel is full (non-blocking, default).
49    BestEffort,
50    /// Overflow to a disk-backed journal when the channel is full.
51    /// Events are retried from disk until delivered.
52    AtLeastOnce,
53}
54
55/// Configuration for the webhook sink.
56#[derive(Debug, Clone)]
57pub struct WebhookConfig {
58    /// Target URL for HTTP POST.
59    pub url: String,
60    /// Output format.
61    pub format: WebhookFormat,
62    /// Channel buffer size (events). Default: 10,000.
63    pub buffer_size: usize,
64    /// HTTP request timeout. Default: 5 seconds.
65    pub timeout: Duration,
66    /// Maximum retry attempts per event. Default: 3.
67    pub max_retries: u32,
68    /// Optional authorization header value (e.g. "Bearer `<token>`").
69    ///
70    /// Supports environment variable expansion: values matching the
71    /// pattern `${VAR_NAME}` are replaced with the contents of the
72    /// corresponding environment variable at sink creation time.  This
73    /// avoids storing plaintext secrets in configuration files.
74    pub auth_header: Option<String>,
75    /// Delivery guarantee mode. Default: `BestEffort`.
76    pub delivery_guarantee: DeliveryGuarantee,
77    /// Directory for the overflow journal (only used in `AtLeastOnce` mode).
78    /// If `None`, falls back to the system temp directory.
79    pub overflow_dir: Option<std::path::PathBuf>,
80}
81
82impl Default for WebhookConfig {
83    fn default() -> Self {
84        Self {
85            url: String::new(),
86            format: WebhookFormat::Json,
87            buffer_size: 10_000,
88            timeout: Duration::from_secs(5),
89            max_retries: 3,
90            auth_header: None,
91            delivery_guarantee: DeliveryGuarantee::BestEffort,
92            overflow_dir: None,
93        }
94    }
95}
96
97/// Format an `AuditEvent` as a CEF string.
98///
99/// CEF format: `CEF:0|Chronix|TSDB|version|action|action_name|severity|extension`
100fn format_cef(event: &AuditEvent) -> String {
101    let severity = match event.decision {
102        crate::audit::model::AuditDecision::Allow => 3,
103        crate::audit::model::AuditDecision::Deny => 7,
104    };
105    let extension = format!(
106        "src={} requestId={} principal={} resource={} decision={}",
107        event.source_ip.as_deref().unwrap_or("-"),
108        event.request_id.as_deref().unwrap_or("-"),
109        event.principal,
110        event.resource,
111        event.decision,
112    );
113    format!(
114        "CEF:0|Chronix|TSDB|1.0|{}|{}|{severity}|{extension}",
115        event.action, event.action,
116    )
117}
118
119/// Expand environment variable references in a string.
120///
121/// Supported patterns:
122/// - `${VAR_NAME}` — replaced with the value of `VAR_NAME` from the environment.
123///
124/// Returns an error if a referenced variable is not set.
125fn expand_env_vars(s: &str) -> std::result::Result<String, String> {
126    let mut result = String::with_capacity(s.len());
127    let mut chars = s.chars().peekable();
128    while let Some(c) = chars.next() {
129        if c == '$' && chars.peek() == Some(&'{') {
130            chars.next(); // consume '{'
131            let mut var_name = String::new();
132            loop {
133                match chars.next() {
134                    Some('}') => break,
135                    Some(ch) => var_name.push(ch),
136                    None => return Err("unclosed ${...} in env var reference".into()),
137                }
138            }
139            match std::env::var(&var_name) {
140                Ok(val) => result.push_str(&val),
141                Err(_) => {
142                    return Err(format!("environment variable '{var_name}' is not set"));
143                }
144            }
145        } else {
146            result.push(c);
147        }
148    }
149    Ok(result)
150}
151
152/// Background worker state.
153struct WebhookWorker {
154    config: WebhookConfig,
155    rx: mpsc::Receiver<AuditEvent>,
156    running: Arc<AtomicBool>,
157    delivered: Arc<AtomicU64>,
158    dropped: Arc<AtomicU64>,
159    overflow: Option<Arc<parking_lot::Mutex<OverflowJournal>>>,
160}
161
162/// Append-only disk-backed overflow journal for AtLeastOnce mode.
163///
164/// Events are written as newline-delimited JSON. On recovery, the worker
165/// reads from the journal before consuming from the channel.
166struct OverflowJournal {
167    path: std::path::PathBuf,
168    /// Pending events read from disk but not yet delivered.
169    pending: std::collections::VecDeque<AuditEvent>,
170    /// Whether we have loaded pending events from the file.
171    loaded: bool,
172}
173
174impl OverflowJournal {
175    fn new(dir: &std::path::Path) -> std::io::Result<Self> {
176        std::fs::create_dir_all(dir)?;
177        let path = dir.join("webhook_overflow.jsonl");
178        Ok(Self {
179            path,
180            pending: std::collections::VecDeque::new(),
181            loaded: false,
182        })
183    }
184
185    /// Append an event to the overflow file.
186    fn append(&mut self, event: &AuditEvent) -> std::io::Result<()> {
187        use std::io::Write;
188        let mut file = std::fs::OpenOptions::new()
189            .create(true)
190            .append(true)
191            .open(&self.path)?;
192        let json = serde_json::to_string(event)
193            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
194        writeln!(file, "{json}")?;
195        file.sync_all()?;
196        metrics::counter!("chronix_audit_webhook_overflow_total").increment(1);
197        Ok(())
198    }
199
200    /// Load all pending events from the overflow file into memory.
201    /// Called once at startup by the worker thread.
202    fn load_pending(&mut self) -> std::io::Result<usize> {
203        if self.loaded {
204            return Ok(self.pending.len());
205        }
206        self.loaded = true;
207        if !self.path.exists() {
208            return Ok(0);
209        }
210        let file = std::fs::File::open(&self.path)?;
211        let reader = std::io::BufReader::new(file);
212        use std::io::BufRead;
213        for line in reader.lines() {
214            let line = line?;
215            if line.is_empty() {
216                continue;
217            }
218            match serde_json::from_str::<AuditEvent>(&line) {
219                Ok(event) => self.pending.push_back(event),
220                Err(e) => {
221                    tracing::warn!(error = %e, "skipping corrupt overflow journal line");
222                }
223            }
224        }
225        let count = self.pending.len();
226        if count > 0 {
227            tracing::info!(count, "loaded pending events from overflow journal");
228        }
229        Ok(count)
230    }
231
232    /// Take the next pending event (from disk recovery).
233    fn pop_pending(&mut self) -> Option<AuditEvent> {
234        self.pending.pop_front()
235    }
236
237    /// Truncate the overflow file (called after all pending events are delivered).
238    fn truncate(&mut self) -> std::io::Result<()> {
239        if self.path.exists() {
240            std::fs::write(&self.path, b"")?;
241        }
242        Ok(())
243    }
244}
245
246impl WebhookWorker {
247    fn run(self) {
248        // In AtLeastOnce mode, drain overflow journal first.
249        if let Some(ref overflow) = self.overflow {
250            let mut guard = overflow.lock();
251            if let Err(e) = guard.load_pending() {
252                tracing::error!(error = %e, "failed to load overflow journal");
253            }
254            let mut delivered_from_overflow = 0u64;
255            while let Some(event) = guard.pop_pending() {
256                if !self.running.load(Ordering::Relaxed) {
257                    break;
258                }
259                // Drop lock while sending to avoid holding it during I/O.
260                drop(guard);
261                self.send_event(&event);
262                delivered_from_overflow += 1;
263                guard = overflow.lock();
264            }
265            if delivered_from_overflow > 0 {
266                tracing::info!(count = delivered_from_overflow, "drained overflow journal");
267                if let Err(e) = guard.truncate() {
268                    tracing::error!(error = %e, "failed to truncate overflow journal");
269                }
270            }
271        }
272
273        while self.running.load(Ordering::Relaxed) {
274            match self.rx.recv_timeout(Duration::from_millis(100)) {
275                Ok(event) => self.send_event(&event),
276                Err(mpsc::RecvTimeoutError::Timeout) => continue,
277                Err(mpsc::RecvTimeoutError::Disconnected) => break,
278            }
279        }
280
281        // Drain remaining events on shutdown.
282        for event in self.rx.try_iter() {
283            self.send_event(&event);
284        }
285    }
286
287    fn send_event(&self, event: &AuditEvent) {
288        let body = match self.config.format {
289            WebhookFormat::Json => match serde_json::to_string(event) {
290                Ok(json) => json,
291                Err(e) => {
292                    tracing::warn!(error = %e, "Failed to serialize audit event");
293                    self.dropped.fetch_add(1, Ordering::Relaxed);
294                    return;
295                }
296            },
297            WebhookFormat::Cef => format_cef(event),
298        };
299
300        let content_type = match self.config.format {
301            WebhookFormat::Json => "application/json",
302            WebhookFormat::Cef => "text/plain",
303        };
304
305        for attempt in 0..=self.config.max_retries {
306            let req = ureq::post(&self.config.url)
307                .header("Content-Type", content_type)
308                .header("User-Agent", "chronix-audit/1.0");
309
310            let req = if let Some(ref auth) = self.config.auth_header {
311                req.header("Authorization", auth)
312            } else {
313                req
314            };
315
316            let req = req
317                .config()
318                .timeout_global(Some(self.config.timeout))
319                .build();
320
321            match req.send(&body) {
322                Ok(resp) => {
323                    if resp.status().is_success() {
324                        self.delivered.fetch_add(1, Ordering::Relaxed);
325                        metrics::counter!("chronix_audit_webhook_delivered_total").increment(1);
326                        return;
327                    }
328                    let status = resp.status().as_u16();
329                    tracing::warn!(
330                        status,
331                        attempt,
332                        url = %self.config.url,
333                        "Webhook returned non-2xx status"
334                    );
335                }
336                Err(e) => {
337                    tracing::warn!(
338                        error = %e,
339                        attempt,
340                        url = %self.config.url,
341                        "Webhook delivery failed"
342                    );
343                }
344            }
345
346            if attempt < self.config.max_retries {
347                let backoff = Duration::from_millis(100 * 2u64.pow(attempt));
348                thread::sleep(backoff);
349            }
350        }
351
352        self.dropped.fetch_add(1, Ordering::Relaxed);
353        metrics::counter!("chronix_audit_webhook_dropped_total").increment(1);
354        tracing::warn!(
355            url = %self.config.url,
356            retries = self.config.max_retries,
357            "Webhook delivery failed after all retries — event dropped"
358        );
359    }
360}
361
362/// HTTP webhook audit sink for SIEM integration.
363///
364/// Events are queued into a bounded channel and delivered asynchronously by
365/// a background thread.  If the channel is full, behaviour depends on the
366/// `DeliveryGuarantee` mode:
367///
368/// - `BestEffort`: event is dropped (non-blocking).
369/// - `AtLeastOnce`: event overflows to a disk-backed journal for later delivery.
370pub struct WebhookSink {
371    tx: mpsc::SyncSender<AuditEvent>,
372    running: Arc<AtomicBool>,
373    handle: parking_lot::Mutex<Option<thread::JoinHandle<()>>>,
374    delivered: Arc<AtomicU64>,
375    dropped: Arc<AtomicU64>,
376    overflow: Option<Arc<parking_lot::Mutex<OverflowJournal>>>,
377    delivery_guarantee: DeliveryGuarantee,
378}
379
380impl WebhookSink {
381    /// Create and start a webhook sink.
382    ///
383    /// Spawns a background thread that consumes events from the channel and
384    /// POSTs them to the configured URL.
385    pub fn new(config: WebhookConfig) -> Self {
386        // Expand environment variable references in auth_header
387        // so that secrets need not be stored in plaintext configuration.
388        let mut config = config;
389        if let Some(ref raw) = config.auth_header {
390            match expand_env_vars(raw) {
391                Ok(expanded) => {
392                    // Reject control characters in expanded auth_header
393                    // to prevent HTTP header injection attacks.
394                    if expanded.chars().any(char::is_control) {
395                        tracing::error!("webhook auth_header contains control characters after expansion — rejecting");
396                        config.auth_header = None;
397                    } else {
398                        config.auth_header = Some(expanded);
399                    }
400                }
401                Err(e) => {
402                    tracing::error!(error = %e, "failed to expand env vars in webhook auth_header");
403                }
404            }
405        }
406
407        let (tx, rx) = mpsc::sync_channel(config.buffer_size);
408        let running = Arc::new(AtomicBool::new(true));
409        let delivered = Arc::new(AtomicU64::new(0));
410        let dropped = Arc::new(AtomicU64::new(0));
411        let delivery_guarantee = config.delivery_guarantee;
412
413        // Set up overflow journal for AtLeastOnce mode.
414        let overflow = if delivery_guarantee == DeliveryGuarantee::AtLeastOnce {
415            let dir = config
416                .overflow_dir
417                .clone()
418                .unwrap_or_else(|| std::env::temp_dir().join("chronix-audit-overflow"));
419            match OverflowJournal::new(&dir) {
420                Ok(journal) => Some(Arc::new(parking_lot::Mutex::new(journal))),
421                Err(e) => {
422                    tracing::error!(error = %e, "failed to create overflow journal — falling back to BestEffort");
423                    None
424                }
425            }
426        } else {
427            None
428        };
429
430        let worker = WebhookWorker {
431            config,
432            rx,
433            running: Arc::clone(&running),
434            delivered: Arc::clone(&delivered),
435            dropped: Arc::clone(&dropped),
436            overflow: overflow.clone(),
437        };
438
439        let handle = thread::Builder::new()
440            .name("chronix-audit-webhook".into())
441            .spawn(move || worker.run())
442            .expect("failed to spawn webhook worker thread");
443
444        Self {
445            tx,
446            running,
447            handle: parking_lot::Mutex::new(Some(handle)),
448            delivered,
449            dropped,
450            overflow,
451            delivery_guarantee,
452        }
453    }
454
455    /// Number of successfully delivered events.
456    #[must_use]
457    pub fn delivered_count(&self) -> u64 {
458        self.delivered.load(Ordering::Relaxed)
459    }
460
461    /// Number of dropped events (channel full or delivery failure).
462    #[must_use]
463    pub fn dropped_count(&self) -> u64 {
464        self.dropped.load(Ordering::Relaxed)
465    }
466
467    /// Gracefully shut down the background worker, draining queued events.
468    pub fn shutdown(&self) {
469        self.running.store(false, Ordering::Relaxed);
470        if let Some(handle) = self.handle.lock().take() {
471            let _ = handle.join();
472        }
473    }
474}
475
476impl super::AuditSink for WebhookSink {
477    fn emit(&self, event: &AuditEvent) -> Result<()> {
478        match self.tx.try_send(event.clone()) {
479            Ok(()) => Ok(()),
480            Err(mpsc::TrySendError::Full(event)) => {
481                // AtLeastOnce mode — overflow to disk.
482                if self.delivery_guarantee == DeliveryGuarantee::AtLeastOnce {
483                    if let Some(ref overflow) = self.overflow {
484                        if let Err(e) = overflow.lock().append(&event) {
485                            tracing::error!(error = %e, "failed to write to overflow journal — event dropped");
486                            self.dropped.fetch_add(1, Ordering::Relaxed);
487                            metrics::counter!("chronix_audit_webhook_dropped_total").increment(1);
488                        }
489                        return Ok(());
490                    }
491                }
492                // BestEffort or overflow unavailable — drop.
493                self.dropped.fetch_add(1, Ordering::Relaxed);
494                metrics::counter!("chronix_audit_webhook_channel_full_total").increment(1);
495                tracing::warn!("Webhook audit channel full — event dropped");
496                Ok(())
497            }
498            Err(mpsc::TrySendError::Disconnected(_)) => Err(AuditError::Internal(
499                "webhook worker thread has stopped".into(),
500            )),
501        }
502    }
503
504    fn flush(&self) -> Result<()> {
505        // Give the worker time to drain.
506        thread::sleep(Duration::from_millis(50));
507        Ok(())
508    }
509}
510
511impl Drop for WebhookSink {
512    fn drop(&mut self) {
513        self.shutdown();
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::audit::model::{AuditAction, AuditDecision};
521    use parking_lot::Mutex;
522    use std::io::{BufRead, BufReader, Read, Write as IoWrite};
523    use std::net::TcpListener;
524
525    fn test_event(principal: &str) -> AuditEvent {
526        AuditEvent {
527            id: 1,
528            timestamp: 1_000_000,
529            principal: principal.to_string(),
530            action: AuditAction::Write,
531            resource: "test_measurement".to_string(),
532            decision: AuditDecision::Allow,
533            source_ip: Some("127.0.0.1".to_string()),
534            request_id: Some("req-1".to_string()),
535            metadata: Default::default(),
536            prev_hash: None,
537            event_hash: None,
538        }
539    }
540
541    /// Spawn a minimal HTTP server that collects request bodies.
542    fn spawn_test_server() -> (String, Arc<Mutex<Vec<String>>>, TcpListener) {
543        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
544        let addr = listener.local_addr().unwrap();
545        let url = format!("http://127.0.0.1:{}", addr.port());
546        let bodies: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
547        (url, bodies, listener)
548    }
549
550    fn handle_requests(listener: &TcpListener, bodies: &Arc<Mutex<Vec<String>>>, count: usize) {
551        listener.set_nonblocking(false).unwrap();
552        for _ in 0..count {
553            let (mut stream, _) = listener.accept().unwrap();
554            stream
555                .set_read_timeout(Some(Duration::from_secs(2)))
556                .unwrap();
557            let mut reader = BufReader::new(stream.try_clone().unwrap());
558            let mut content_length: Option<usize> = None;
559            let mut is_chunked = false;
560
561            // Read headers (case-insensitive)
562            loop {
563                let mut line = String::new();
564                if reader.read_line(&mut line).is_err() {
565                    break;
566                }
567                if line.trim().is_empty() {
568                    break;
569                }
570                let lower = line.to_ascii_lowercase();
571                if let Some(val) = lower.strip_prefix("content-length:") {
572                    content_length = val.trim().parse().ok();
573                }
574                if lower.starts_with("transfer-encoding:") && lower.contains("chunked") {
575                    is_chunked = true;
576                }
577            }
578
579            // Read body
580            let body_str = if is_chunked {
581                // Read chunked encoding
582                let mut body = Vec::new();
583                loop {
584                    let mut size_line = String::new();
585                    if reader.read_line(&mut size_line).is_err() {
586                        break;
587                    }
588                    let chunk_size = usize::from_str_radix(size_line.trim(), 16).unwrap_or(0);
589                    if chunk_size == 0 {
590                        // Read trailing \r\n
591                        let mut _trail = String::new();
592                        let _ = reader.read_line(&mut _trail);
593                        break;
594                    }
595                    let mut chunk = vec![0u8; chunk_size];
596                    let _ = reader.read_exact(&mut chunk);
597                    body.extend_from_slice(&chunk);
598                    // Read trailing \r\n after chunk data
599                    let mut _crlf = String::new();
600                    let _ = reader.read_line(&mut _crlf);
601                }
602                String::from_utf8_lossy(&body).to_string()
603            } else if let Some(len) = content_length {
604                let mut buf = vec![0u8; len];
605                let _ = reader.read_exact(&mut buf);
606                String::from_utf8_lossy(&buf).to_string()
607            } else {
608                String::new()
609            };
610
611            bodies.lock().push(body_str);
612
613            // Respond 200
614            let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
615            let _ = stream.write_all(response.as_bytes());
616        }
617    }
618
619    #[test]
620    fn webhook_delivers_json_event() {
621        let (url, bodies, listener) = spawn_test_server();
622
623        let config = WebhookConfig {
624            url,
625            format: WebhookFormat::Json,
626            buffer_size: 100,
627            timeout: Duration::from_secs(2),
628            max_retries: 0,
629            auth_header: None,
630            ..Default::default()
631        };
632
633        let sink = WebhookSink::new(config);
634        let event = test_event("alice");
635
636        // Accept connections in a thread
637        let bodies_clone = Arc::clone(&bodies);
638        let server = thread::spawn(move || {
639            handle_requests(&listener, &bodies_clone, 1);
640        });
641
642        super::super::AuditSink::emit(&sink, &event).unwrap();
643        thread::sleep(Duration::from_millis(200));
644        sink.shutdown();
645        server.join().unwrap();
646
647        let received = bodies.lock();
648        assert_eq!(received.len(), 1);
649        let parsed: serde_json::Value = serde_json::from_str(&received[0]).unwrap();
650        assert_eq!(parsed["principal"], "alice");
651    }
652
653    #[test]
654    fn webhook_delivers_cef_event() {
655        let (url, bodies, listener) = spawn_test_server();
656
657        let config = WebhookConfig {
658            url,
659            format: WebhookFormat::Cef,
660            buffer_size: 100,
661            timeout: Duration::from_secs(2),
662            max_retries: 0,
663            auth_header: None,
664            ..Default::default()
665        };
666
667        let sink = WebhookSink::new(config);
668        let event = test_event("bob");
669
670        let bodies_clone = Arc::clone(&bodies);
671        let server = thread::spawn(move || {
672            handle_requests(&listener, &bodies_clone, 1);
673        });
674
675        super::super::AuditSink::emit(&sink, &event).unwrap();
676        thread::sleep(Duration::from_millis(200));
677        sink.shutdown();
678        server.join().unwrap();
679
680        let received = bodies.lock();
681        assert_eq!(received.len(), 1);
682        assert!(received[0].starts_with("CEF:0|Chronix|TSDB|"));
683        assert!(received[0].contains("principal=bob"));
684    }
685
686    #[test]
687    fn cef_format_deny_severity() {
688        let mut event = test_event("mallory");
689        event.decision = AuditDecision::Deny;
690        let cef = format_cef(&event);
691        // Deny should have severity 7
692        assert!(cef.contains("|7|"));
693    }
694
695    #[test]
696    fn webhook_drops_when_channel_full() {
697        let config = WebhookConfig {
698            url: "http://127.0.0.1:1".to_string(), // unreachable
699            format: WebhookFormat::Json,
700            buffer_size: 2,
701            timeout: Duration::from_millis(50),
702            max_retries: 0,
703            auth_header: None,
704            ..Default::default()
705        };
706
707        let sink = WebhookSink::new(config);
708
709        // Fill the channel
710        for i in 0..10 {
711            let event = test_event(&format!("user_{i}"));
712            let _ = super::super::AuditSink::emit(&sink, &event);
713        }
714
715        // Some should be dropped due to full channel
716        assert!(sink.dropped_count() > 0);
717        sink.shutdown();
718    }
719
720    #[test]
721    fn webhook_shutdown_drains() {
722        let (url, bodies, listener) = spawn_test_server();
723
724        let config = WebhookConfig {
725            url,
726            format: WebhookFormat::Json,
727            buffer_size: 100,
728            timeout: Duration::from_secs(2),
729            max_retries: 0,
730            auth_header: None,
731            ..Default::default()
732        };
733
734        let sink = WebhookSink::new(config);
735
736        let bodies_clone = Arc::clone(&bodies);
737        let server = thread::spawn(move || {
738            handle_requests(&listener, &bodies_clone, 3);
739        });
740
741        for i in 0..3 {
742            let event = test_event(&format!("user_{i}"));
743            super::super::AuditSink::emit(&sink, &event).unwrap();
744        }
745
746        // Shutdown should drain
747        sink.shutdown();
748        server.join().unwrap();
749
750        let received = bodies.lock();
751        assert_eq!(received.len(), 3);
752    }
753
754    #[test]
755    fn overflow_journal_roundtrip() {
756        let dir = tempfile::tempdir().unwrap();
757        let mut journal = OverflowJournal::new(dir.path()).unwrap();
758        let event = test_event("overflow-user");
759
760        // Append two events
761        journal.append(&event).unwrap();
762        journal.append(&event).unwrap();
763
764        // Load and verify
765        let count = journal.load_pending().unwrap();
766        assert_eq!(count, 2);
767        let e1 = journal.pop_pending().unwrap();
768        assert_eq!(e1.principal, "overflow-user");
769        let e2 = journal.pop_pending().unwrap();
770        assert_eq!(e2.principal, "overflow-user");
771        assert!(journal.pop_pending().is_none());
772
773        // Truncate
774        journal.truncate().unwrap();
775        let mut journal2 = OverflowJournal::new(dir.path()).unwrap();
776        let count2 = journal2.load_pending().unwrap();
777        assert_eq!(count2, 0);
778    }
779
780    #[test]
781    fn at_least_once_overflows_to_disk() {
782        // Use a buffer_size=1 so events overflow immediately.
783        let dir = tempfile::tempdir().unwrap();
784        let (url, bodies, listener) = spawn_test_server();
785
786        let config = WebhookConfig {
787            url,
788            buffer_size: 1,
789            delivery_guarantee: DeliveryGuarantee::AtLeastOnce,
790            overflow_dir: Some(dir.path().to_path_buf()),
791            ..Default::default()
792        };
793
794        let sink = WebhookSink::new(config);
795
796        // Fill the channel (1 slot) + overflow events.
797        // The first event goes into the channel, subsequent ones overflow.
798        for i in 0..3 {
799            let event = test_event(&format!("user_{i}"));
800            super::super::AuditSink::emit(&sink, &event).unwrap();
801        }
802
803        // Verify overflow file exists and has content
804        let overflow_path = dir.path().join("webhook_overflow.jsonl");
805        assert!(overflow_path.exists());
806        let content = std::fs::read_to_string(&overflow_path).unwrap();
807        assert!(!content.is_empty(), "overflow journal should have content");
808
809        // Now accept events from the server
810        let bodies_clone = Arc::clone(&bodies);
811        let server = thread::spawn(move || {
812            // Accept at least the channel event
813            handle_requests(&listener, &bodies_clone, 1);
814        });
815
816        // Shutdown drains both channel and overflow
817        sink.shutdown();
818        let _ = server.join();
819
820        // At least the channel event was delivered
821        let received = bodies.lock();
822        assert!(!received.is_empty());
823    }
824
825    #[test]
826    fn expand_env_vars_replaces_variable() {
827        std::env::set_var("CHRONIX_TEST_TOKEN_9182", "secret-value");
828        let result = super::expand_env_vars("Bearer ${CHRONIX_TEST_TOKEN_9182}").unwrap();
829        assert_eq!(result, "Bearer secret-value");
830        std::env::remove_var("CHRONIX_TEST_TOKEN_9182");
831    }
832
833    #[test]
834    fn expand_env_vars_passthrough_literal() {
835        let result = super::expand_env_vars("Bearer my-literal-token").unwrap();
836        assert_eq!(result, "Bearer my-literal-token");
837    }
838
839    #[test]
840    fn expand_env_vars_missing_var_errors() {
841        let result = super::expand_env_vars("${CHRONIX_NONEXISTENT_VAR_XYZ}");
842        assert!(result.is_err());
843    }
844
845    #[test]
846    fn expand_env_vars_unclosed_brace_errors() {
847        let result = super::expand_env_vars("${UNCLOSED");
848        assert!(result.is_err());
849    }
850
851    #[test]
852    fn expand_env_vars_multiple_vars() {
853        std::env::set_var("CHRONIX_TEST_A_3847", "hello");
854        std::env::set_var("CHRONIX_TEST_B_3847", "world");
855        let result =
856            super::expand_env_vars("${CHRONIX_TEST_A_3847} ${CHRONIX_TEST_B_3847}").unwrap();
857        assert_eq!(result, "hello world");
858        std::env::remove_var("CHRONIX_TEST_A_3847");
859        std::env::remove_var("CHRONIX_TEST_B_3847");
860    }
861}