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