Skip to main content

oximedia_workflow/
triggers.rs

1//! Workflow trigger system.
2//!
3//! Provides flexible trigger types for initiating workflow execution,
4//! including schedule-based, file arrival, API, event-based, and webhook triggers.
5//!
6//! # Webhook Triggers
7//!
8//! The `Webhook` trigger variant allows external systems to start a workflow by
9//! sending an HTTP POST request to a registered endpoint. The
10//! [`WebhookTrigger`] struct describes the expected path, optional HMAC-SHA256
11//! secret validation, and field filters that must be present in the JSON body.
12//!
13//! The [`WebhookRouter`] provides in-process matching: given a raw HTTP path and
14//! JSON body, it returns the workflow IDs whose webhook triggers are satisfied.
15//! Actual HTTP server integration is left to the caller (axum, hyper, etc.).
16
17#![allow(dead_code)]
18
19use std::collections::HashMap;
20
21/// Trigger type for workflow execution.
22#[derive(Debug, Clone)]
23pub enum TriggerType {
24    /// Cron-style schedule trigger.
25    Schedule(ScheduleTrigger),
26    /// File arrival trigger.
27    FileArrival(FileArrivalTrigger),
28    /// API call trigger.
29    ApiCall,
30    /// Event-based trigger.
31    EventBased(EventTrigger),
32    /// Manual start trigger.
33    ManualStart,
34    /// Dependency-based trigger.
35    Dependency,
36    /// HTTP POST webhook trigger.
37    Webhook(WebhookTrigger),
38}
39
40/// Schedule trigger using cron expressions.
41#[derive(Debug, Clone)]
42pub struct ScheduleTrigger {
43    /// Cron expression (e.g. "0 9 * * 1-5" for weekdays at 9am).
44    pub cron_expr: String,
45    /// Timezone identifier (e.g. "UTC", "`America/New_York`").
46    pub timezone: String,
47    /// Maximum number of runs (None = unlimited).
48    pub max_runs: Option<u32>,
49}
50
51impl ScheduleTrigger {
52    /// Create a new schedule trigger.
53    #[must_use]
54    pub fn new(cron_expr: impl Into<String>, timezone: impl Into<String>) -> Self {
55        Self {
56            cron_expr: cron_expr.into(),
57            timezone: timezone.into(),
58            max_runs: None,
59        }
60    }
61
62    /// Set maximum runs.
63    #[must_use]
64    pub fn with_max_runs(mut self, max_runs: u32) -> Self {
65        self.max_runs = Some(max_runs);
66        self
67    }
68
69    /// Calculate next fire time in milliseconds.
70    ///
71    /// Simplified implementation: parses HH:MM from cron expression
72    /// (fields: second minute hour day month weekday).
73    /// Returns the next fire time in ms from `now_ms`.
74    #[must_use]
75    pub fn next_fire_ms(&self, now_ms: u64) -> u64 {
76        // Parse HH:MM from cron: expect format "S M H ..."
77        // We extract field index 2 (hour) and 1 (minute).
78        let parts: Vec<&str> = self.cron_expr.split_whitespace().collect();
79        if parts.len() < 3 {
80            // Default: fire in 1 hour
81            return now_ms + 3_600_000;
82        }
83
84        let minute: u64 = parts[1].parse().unwrap_or(0);
85        let hour: u64 = parts[2].parse().unwrap_or(0);
86
87        // Current time components from ms
88        let now_secs = now_ms / 1000;
89        let seconds_in_day = now_secs % 86400;
90        let current_hour = seconds_in_day / 3600;
91        let current_minute = (seconds_in_day % 3600) / 60;
92        let day_start_ms = now_ms - (seconds_in_day * 1000);
93
94        let target_ms = day_start_ms + hour * 3_600_000 + minute * 60_000;
95
96        if target_ms > now_ms {
97            target_ms
98        } else if hour == current_hour && minute == current_minute {
99            // Same minute - fire in next minute
100            now_ms + 60_000
101        } else {
102            // Tomorrow same time
103            target_ms + 86_400_000
104        }
105    }
106}
107
108/// File arrival trigger configuration.
109#[derive(Debug, Clone)]
110pub struct FileArrivalTrigger {
111    /// Directory path to watch.
112    pub watch_path: String,
113    /// File pattern to match (glob-style, supports `*` wildcard).
114    pub pattern: String,
115    /// Minimum file size in bytes.
116    pub min_size_bytes: u64,
117    /// Wait for file to be stable for this many seconds.
118    pub stable_for_secs: u32,
119}
120
121impl FileArrivalTrigger {
122    /// Create a new file arrival trigger.
123    #[must_use]
124    pub fn new(
125        watch_path: impl Into<String>,
126        pattern: impl Into<String>,
127        min_size_bytes: u64,
128        stable_for_secs: u32,
129    ) -> Self {
130        Self {
131            watch_path: watch_path.into(),
132            pattern: pattern.into(),
133            min_size_bytes,
134            stable_for_secs,
135        }
136    }
137
138    /// Check if a file path and size matches this trigger's criteria.
139    ///
140    /// Supports glob-style pattern with `*` wildcard matching any sequence of characters.
141    #[must_use]
142    pub fn matches(&self, path: &str, size: u64) -> bool {
143        if size < self.min_size_bytes {
144            return false;
145        }
146
147        // Extract filename from path
148        let filename = path.rsplit('/').next().unwrap_or(path);
149
150        glob_match(&self.pattern, filename)
151    }
152}
153
154/// Glob-style pattern matching with `*` wildcard.
155fn glob_match(pattern: &str, text: &str) -> bool {
156    let pattern_bytes = pattern.as_bytes();
157    let text_bytes = text.as_bytes();
158    glob_match_inner(pattern_bytes, text_bytes)
159}
160
161fn glob_match_inner(pattern: &[u8], text: &[u8]) -> bool {
162    match (pattern.first(), text.first()) {
163        (None, None) => true,
164        (Some(&b'*'), _) => {
165            // Try matching * with 0 characters, then 1, 2, ... characters
166            glob_match_inner(&pattern[1..], text)
167                || (!text.is_empty() && glob_match_inner(pattern, &text[1..]))
168        }
169        (None, Some(_)) | (Some(_), None) => false,
170        (Some(&p), Some(&t)) => p == t && glob_match_inner(&pattern[1..], &text[1..]),
171    }
172}
173
174/// Event-based trigger.
175#[derive(Debug, Clone)]
176pub struct EventTrigger {
177    /// Type of event to watch for.
178    pub event_type: String,
179    /// Key-value filter conditions that must all match.
180    pub filter: HashMap<String, String>,
181}
182
183impl EventTrigger {
184    /// Create a new event trigger.
185    #[must_use]
186    pub fn new(event_type: impl Into<String>) -> Self {
187        Self {
188            event_type: event_type.into(),
189            filter: HashMap::new(),
190        }
191    }
192
193    /// Add a filter condition.
194    #[must_use]
195    pub fn with_filter(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
196        self.filter.insert(key.into(), value.into());
197        self
198    }
199
200    /// Check if an event matches this trigger's conditions.
201    #[must_use]
202    pub fn matches(&self, event: &HashMap<String, String>) -> bool {
203        for (key, expected_value) in &self.filter {
204            match event.get(key) {
205                Some(actual_value) if actual_value == expected_value => {}
206                _ => return false,
207            }
208        }
209        true
210    }
211}
212
213// ---------------------------------------------------------------------------
214// Webhook trigger
215// ---------------------------------------------------------------------------
216
217/// Configuration for an HTTP POST webhook trigger.
218///
219/// When an HTTP POST arrives at `path`, the JSON body is decoded and checked:
220/// 1. If `secret` is set, the `X-Hub-Signature-256` header must contain a valid
221///    HMAC-SHA256 signature of the raw body (pure-Rust implementation).
222/// 2. All entries in `required_fields` must be present in the JSON object with
223///    the expected string values.
224///
225/// Real HTTP integration is the caller's responsibility; see [`WebhookRouter`].
226#[derive(Debug, Clone)]
227pub struct WebhookTrigger {
228    /// URL path that activates this trigger (e.g. `"/webhooks/ingest-ready"`).
229    pub path: String,
230    /// Optional HMAC-SHA256 secret for signature verification.
231    pub secret: Option<String>,
232    /// JSON body fields that must match to fire the trigger.
233    pub required_fields: HashMap<String, String>,
234    /// Optional description.
235    pub description: String,
236}
237
238impl WebhookTrigger {
239    /// Create a new webhook trigger for the given path.
240    #[must_use]
241    pub fn new(path: impl Into<String>) -> Self {
242        Self {
243            path: path.into(),
244            secret: None,
245            required_fields: HashMap::new(),
246            description: String::new(),
247        }
248    }
249
250    /// Set an HMAC-SHA256 secret for signature verification.
251    #[must_use]
252    pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
253        self.secret = Some(secret.into());
254        self
255    }
256
257    /// Require a JSON body field to have a specific value.
258    #[must_use]
259    pub fn with_required_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
260        self.required_fields.insert(key.into(), value.into());
261        self
262    }
263
264    /// Set a human-readable description.
265    #[must_use]
266    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
267        self.description = desc.into();
268        self
269    }
270
271    /// Verify the HMAC-SHA256 signature of a raw payload.
272    ///
273    /// `signature` is the hex-encoded digest (e.g. the value of the
274    /// `X-Hub-Signature-256` header without any `sha256=` prefix).
275    ///
276    /// Returns `true` when the signature is valid *or* no secret is configured.
277    #[must_use]
278    pub fn verify_signature(&self, payload: &[u8], signature: &str) -> bool {
279        let Some(ref secret) = self.secret else {
280            return true; // no secret configured → always valid
281        };
282        let expected = hmac_sha256(secret.as_bytes(), payload);
283        // Compare in constant time to prevent timing attacks.
284        constant_time_eq(&expected, signature.trim())
285    }
286
287    /// Check whether a parsed JSON body satisfies the required-fields filter.
288    #[must_use]
289    pub fn body_matches(&self, body: &serde_json::Value) -> bool {
290        for (key, expected) in &self.required_fields {
291            match body.get(key).and_then(|v| v.as_str()) {
292                Some(actual) if actual == expected.as_str() => {}
293                _ => return false,
294            }
295        }
296        true
297    }
298}
299
300// ---------------------------------------------------------------------------
301// HMAC-SHA256 pure-Rust implementation (no external crypto crate)
302// ---------------------------------------------------------------------------
303
304/// Compute HMAC-SHA256 of `message` keyed with `key`.
305///
306/// Returns the hex-encoded digest.
307fn hmac_sha256(key: &[u8], message: &[u8]) -> String {
308    // SHA-256 block size = 64 bytes
309    const BLOCK: usize = 64;
310
311    // Prepare K: truncate or hash key if longer than block size.
312    let mut k = [0u8; BLOCK];
313    if key.len() > BLOCK {
314        let h = sha256(key);
315        k[..32].copy_from_slice(&h);
316    } else {
317        k[..key.len()].copy_from_slice(key);
318    }
319
320    // i_pad and o_pad XOR
321    let mut i_key_pad = [0u8; BLOCK];
322    let mut o_key_pad = [0u8; BLOCK];
323    for i in 0..BLOCK {
324        i_key_pad[i] = k[i] ^ 0x36;
325        o_key_pad[i] = k[i] ^ 0x5c;
326    }
327
328    // inner = SHA256(i_key_pad || message)
329    let mut inner_input = Vec::with_capacity(BLOCK + message.len());
330    inner_input.extend_from_slice(&i_key_pad);
331    inner_input.extend_from_slice(message);
332    let inner_hash = sha256(&inner_input);
333
334    // outer = SHA256(o_key_pad || inner)
335    let mut outer_input = Vec::with_capacity(BLOCK + 32);
336    outer_input.extend_from_slice(&o_key_pad);
337    outer_input.extend_from_slice(&inner_hash);
338    let outer_hash = sha256(&outer_input);
339
340    // Hex-encode
341    outer_hash
342        .iter()
343        .fold(String::with_capacity(64), |mut s, b| {
344            s.push_str(&format!("{b:02x}"));
345            s
346        })
347}
348
349/// Minimal SHA-256 implementation (NIST FIPS 180-4).
350#[allow(clippy::many_single_char_names)]
351fn sha256(data: &[u8]) -> [u8; 32] {
352    // Initial hash values (first 32 bits of fractional parts of sqrt of first 8 primes)
353    let mut h: [u32; 8] = [
354        0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
355        0x5be0cd19,
356    ];
357
358    // Round constants (first 32 bits of fractional parts of cbrt of first 64 primes)
359    const K: [u32; 64] = [
360        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
361        0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
362        0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
363        0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
364        0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
365        0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
366        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
367        0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
368        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
369        0xc67178f2,
370    ];
371
372    // Pre-processing: adding padding bits
373    let mut msg = data.to_vec();
374    let bit_len = (data.len() as u64).wrapping_mul(8);
375    msg.push(0x80);
376    while (msg.len() % 64) != 56 {
377        msg.push(0x00);
378    }
379    msg.extend_from_slice(&bit_len.to_be_bytes());
380
381    // Process each 512-bit (64-byte) chunk
382    for chunk in msg.chunks(64) {
383        let mut w = [0u32; 64];
384        for (i, word_bytes) in chunk.chunks(4).enumerate().take(16) {
385            w[i] = u32::from_be_bytes([word_bytes[0], word_bytes[1], word_bytes[2], word_bytes[3]]);
386        }
387        for i in 16..64 {
388            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
389            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
390            w[i] = w[i - 16]
391                .wrapping_add(s0)
392                .wrapping_add(w[i - 7])
393                .wrapping_add(s1);
394        }
395
396        let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
397
398        for i in 0..64 {
399            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
400            let ch = (e & f) ^ ((!e) & g);
401            let temp1 = hh
402                .wrapping_add(s1)
403                .wrapping_add(ch)
404                .wrapping_add(K[i])
405                .wrapping_add(w[i]);
406            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
407            let maj = (a & b) ^ (a & c) ^ (b & c);
408            let temp2 = s0.wrapping_add(maj);
409
410            hh = g;
411            g = f;
412            f = e;
413            e = d.wrapping_add(temp1);
414            d = c;
415            c = b;
416            b = a;
417            a = temp1.wrapping_add(temp2);
418        }
419
420        h[0] = h[0].wrapping_add(a);
421        h[1] = h[1].wrapping_add(b);
422        h[2] = h[2].wrapping_add(c);
423        h[3] = h[3].wrapping_add(d);
424        h[4] = h[4].wrapping_add(e);
425        h[5] = h[5].wrapping_add(f);
426        h[6] = h[6].wrapping_add(g);
427        h[7] = h[7].wrapping_add(hh);
428    }
429
430    let mut digest = [0u8; 32];
431    for (i, &word) in h.iter().enumerate() {
432        digest[i * 4..(i + 1) * 4].copy_from_slice(&word.to_be_bytes());
433    }
434    digest
435}
436
437/// Constant-time byte equality to prevent timing attacks.
438fn constant_time_eq(a: &str, b: &str) -> bool {
439    let ab = a.as_bytes();
440    let bb = b.as_bytes();
441    if ab.len() != bb.len() {
442        return false;
443    }
444    let mut diff = 0u8;
445    for (x, y) in ab.iter().zip(bb.iter()) {
446        diff |= x ^ y;
447    }
448    diff == 0
449}
450
451// ---------------------------------------------------------------------------
452// WebhookRouter
453// ---------------------------------------------------------------------------
454
455/// Routes incoming HTTP POST webhooks to the matching registered workflow IDs.
456///
457/// This is an in-process routing layer. Callers are expected to extract the
458/// HTTP path, raw body bytes, and optional signature header, then call
459/// [`WebhookRouter::route`] to obtain the list of matching workflow IDs.
460#[derive(Debug, Default)]
461pub struct WebhookRouter {
462    /// Map from workflow ID to its list of webhook triggers.
463    routes: HashMap<String, Vec<WebhookTrigger>>,
464}
465
466impl WebhookRouter {
467    /// Create an empty router.
468    #[must_use]
469    pub fn new() -> Self {
470        Self::default()
471    }
472
473    /// Register a webhook trigger for a workflow.
474    pub fn register(&mut self, workflow_id: impl Into<String>, trigger: WebhookTrigger) {
475        self.routes
476            .entry(workflow_id.into())
477            .or_default()
478            .push(trigger);
479    }
480
481    /// Remove all webhook triggers for a workflow.
482    pub fn remove(&mut self, workflow_id: &str) {
483        self.routes.remove(workflow_id);
484    }
485
486    /// Evaluate an incoming HTTP POST and return workflow IDs that should be triggered.
487    ///
488    /// # Parameters
489    /// - `path`: The URL path of the request (e.g. `"/webhooks/ingest-ready"`).
490    /// - `body_bytes`: Raw request body bytes (used for HMAC verification).
491    /// - `body_json`: Parsed JSON body (used for field matching).
492    /// - `signature`: Optional value of the `X-Hub-Signature-256` header.
493    ///
494    /// A workflow is triggered when **at least one** of its webhook triggers
495    /// matches the path, passes signature verification, and satisfies all
496    /// required body fields.
497    #[must_use]
498    pub fn route(
499        &self,
500        path: &str,
501        body_bytes: &[u8],
502        body_json: &serde_json::Value,
503        signature: Option<&str>,
504    ) -> Vec<String> {
505        let mut triggered = Vec::new();
506
507        for (workflow_id, triggers) in &self.routes {
508            for trigger in triggers {
509                if trigger.path != path {
510                    continue;
511                }
512                // Verify signature if a secret is configured
513                let sig = signature.unwrap_or("");
514                if !trigger.verify_signature(body_bytes, sig) {
515                    continue;
516                }
517                if trigger.body_matches(body_json) {
518                    triggered.push(workflow_id.clone());
519                    break; // Only trigger once per workflow
520                }
521            }
522        }
523
524        triggered
525    }
526
527    /// List all registered workflow IDs.
528    #[must_use]
529    pub fn workflow_ids(&self) -> Vec<&str> {
530        self.routes.keys().map(String::as_str).collect()
531    }
532
533    /// Count total registered triggers across all workflows.
534    #[must_use]
535    pub fn trigger_count(&self) -> usize {
536        self.routes.values().map(Vec::len).sum()
537    }
538}
539
540/// Condition that combines multiple triggers.
541#[derive(Debug, Clone)]
542pub enum TriggerCondition {
543    /// All triggers must fire.
544    All(Vec<TriggerType>),
545    /// Any trigger can fire.
546    Any(Vec<TriggerType>),
547    /// No triggers should fire (negation).
548    None(Vec<TriggerType>),
549}
550
551/// Engine that manages triggers and evaluates them.
552#[derive(Debug, Default)]
553pub struct TriggerEngine {
554    /// Map from workflow ID to its list of triggers.
555    triggers: HashMap<String, Vec<TriggerType>>,
556}
557
558impl TriggerEngine {
559    /// Create a new trigger engine.
560    #[must_use]
561    pub fn new() -> Self {
562        Self {
563            triggers: HashMap::new(),
564        }
565    }
566
567    /// Add a trigger for a workflow.
568    pub fn add_trigger(&mut self, workflow_id: &str, trigger: TriggerType) {
569        self.triggers
570            .entry(workflow_id.to_string())
571            .or_default()
572            .push(trigger);
573    }
574
575    /// Evaluate file arrival event and return workflow IDs that should be triggered.
576    #[must_use]
577    pub fn evaluate_file_arrival(&self, path: &str, size: u64) -> Vec<String> {
578        let mut triggered = Vec::new();
579
580        for (workflow_id, triggers) in &self.triggers {
581            for trigger in triggers {
582                if let TriggerType::FileArrival(file_trigger) = trigger {
583                    if file_trigger.matches(path, size) {
584                        triggered.push(workflow_id.clone());
585                        break; // Only trigger once per workflow
586                    }
587                }
588            }
589        }
590
591        triggered
592    }
593
594    /// Evaluate an event and return workflow IDs that should be triggered.
595    #[must_use]
596    pub fn evaluate_event(&self, event: &HashMap<String, String>) -> Vec<String> {
597        let mut triggered = Vec::new();
598
599        for (workflow_id, triggers) in &self.triggers {
600            for trigger in triggers {
601                if let TriggerType::EventBased(event_trigger) = trigger {
602                    if event_trigger.matches(event) {
603                        triggered.push(workflow_id.clone());
604                        break;
605                    }
606                }
607            }
608        }
609
610        triggered
611    }
612
613    /// Get triggers for a workflow.
614    #[must_use]
615    pub fn get_triggers(&self, workflow_id: &str) -> &[TriggerType] {
616        self.triggers.get(workflow_id).map_or(&[], Vec::as_slice)
617    }
618
619    /// Remove all triggers for a workflow.
620    pub fn remove_workflow(&mut self, workflow_id: &str) {
621        self.triggers.remove(workflow_id);
622    }
623
624    /// List all registered workflow IDs.
625    #[must_use]
626    pub fn workflow_ids(&self) -> Vec<&str> {
627        self.triggers.keys().map(String::as_str).collect()
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn test_schedule_trigger_next_fire_future_today() {
637        // Set now to 8:00 AM (in ms), trigger at 9:00 AM
638        let now_ms = 8 * 3_600_000_u64; // 8h in ms from day start
639        let trigger = ScheduleTrigger::new("0 0 9 * * *", "UTC");
640        let next = trigger.next_fire_ms(now_ms);
641        // Should be 9:00 AM = 9 * 3_600_000
642        assert_eq!(next, 9 * 3_600_000);
643    }
644
645    #[test]
646    fn test_schedule_trigger_next_fire_tomorrow() {
647        // Set now to 10:00 AM, trigger at 9:00 AM → tomorrow
648        let now_ms = 10 * 3_600_000_u64;
649        let trigger = ScheduleTrigger::new("0 0 9 * * *", "UTC");
650        let next = trigger.next_fire_ms(now_ms);
651        assert!(next > now_ms);
652    }
653
654    #[test]
655    fn test_schedule_trigger_with_max_runs() {
656        let trigger = ScheduleTrigger::new("0 0 9 * * *", "UTC").with_max_runs(5);
657        assert_eq!(trigger.max_runs, Some(5));
658    }
659
660    #[test]
661    fn test_file_arrival_trigger_matches_pattern() {
662        let trigger = FileArrivalTrigger::new("/watch", "*.mp4", 1000, 5);
663        assert!(trigger.matches("/watch/video.mp4", 5000));
664        assert!(!trigger.matches("/watch/video.mp4", 500)); // too small
665        assert!(!trigger.matches("/watch/video.mov", 5000)); // wrong ext
666    }
667
668    #[test]
669    fn test_file_arrival_trigger_wildcard_pattern() {
670        let trigger = FileArrivalTrigger::new("/ingest", "mxf_*_v2.mxf", 0, 0);
671        assert!(trigger.matches("/ingest/mxf_cam1_v2.mxf", 0));
672        assert!(!trigger.matches("/ingest/mxf_cam1_v1.mxf", 0));
673    }
674
675    #[test]
676    fn test_file_arrival_trigger_no_wildcard() {
677        let trigger = FileArrivalTrigger::new("/dir", "exact.mp4", 0, 0);
678        assert!(trigger.matches("/dir/exact.mp4", 0));
679        assert!(!trigger.matches("/dir/other.mp4", 0));
680    }
681
682    #[test]
683    fn test_event_trigger_matches() {
684        let trigger = EventTrigger::new("media.ready")
685            .with_filter("format", "mp4")
686            .with_filter("resolution", "4k");
687
688        let mut event = HashMap::new();
689        event.insert("format".to_string(), "mp4".to_string());
690        event.insert("resolution".to_string(), "4k".to_string());
691        event.insert("extra_field".to_string(), "ignored".to_string());
692
693        assert!(trigger.matches(&event));
694    }
695
696    #[test]
697    fn test_event_trigger_no_match() {
698        let trigger = EventTrigger::new("media.ready").with_filter("format", "mp4");
699
700        let mut event = HashMap::new();
701        event.insert("format".to_string(), "mov".to_string());
702
703        assert!(!trigger.matches(&event));
704    }
705
706    #[test]
707    fn test_event_trigger_empty_filter() {
708        let trigger = EventTrigger::new("any.event");
709        let event = HashMap::new();
710        assert!(trigger.matches(&event));
711    }
712
713    #[test]
714    fn test_trigger_engine_add_and_evaluate_file() {
715        let mut engine = TriggerEngine::new();
716        engine.add_trigger(
717            "workflow-1",
718            TriggerType::FileArrival(FileArrivalTrigger::new("/ingest", "*.mxf", 1000, 5)),
719        );
720        engine.add_trigger(
721            "workflow-2",
722            TriggerType::FileArrival(FileArrivalTrigger::new("/ingest", "*.mp4", 1000, 5)),
723        );
724
725        let triggered = engine.evaluate_file_arrival("/ingest/clip.mxf", 50_000);
726        assert_eq!(triggered.len(), 1);
727        assert_eq!(triggered[0], "workflow-1");
728    }
729
730    #[test]
731    fn test_trigger_engine_multiple_workflows() {
732        let mut engine = TriggerEngine::new();
733        engine.add_trigger(
734            "wf-a",
735            TriggerType::FileArrival(FileArrivalTrigger::new("/watch", "*.mp4", 0, 0)),
736        );
737        engine.add_trigger(
738            "wf-b",
739            TriggerType::FileArrival(FileArrivalTrigger::new("/watch", "*.mp4", 0, 0)),
740        );
741
742        let triggered = engine.evaluate_file_arrival("/watch/test.mp4", 1);
743        assert_eq!(triggered.len(), 2);
744    }
745
746    #[test]
747    fn test_trigger_engine_no_match() {
748        let mut engine = TriggerEngine::new();
749        engine.add_trigger(
750            "wf-1",
751            TriggerType::FileArrival(FileArrivalTrigger::new("/watch", "*.mxf", 0, 0)),
752        );
753
754        let triggered = engine.evaluate_file_arrival("/watch/test.mp4", 1);
755        assert!(triggered.is_empty());
756    }
757
758    #[test]
759    fn test_trigger_engine_remove_workflow() {
760        let mut engine = TriggerEngine::new();
761        engine.add_trigger(
762            "wf-1",
763            TriggerType::FileArrival(FileArrivalTrigger::new("/watch", "*.mp4", 0, 0)),
764        );
765
766        engine.remove_workflow("wf-1");
767
768        let triggered = engine.evaluate_file_arrival("/watch/test.mp4", 1);
769        assert!(triggered.is_empty());
770    }
771
772    #[test]
773    fn test_trigger_condition_variants() {
774        let triggers = vec![TriggerType::ManualStart, TriggerType::ApiCall];
775        let _all = TriggerCondition::All(triggers.clone());
776        let _any = TriggerCondition::Any(triggers.clone());
777        let _none = TriggerCondition::None(triggers);
778        // Just verify construction works
779    }
780
781    #[test]
782    fn test_glob_match_star_extension() {
783        assert!(glob_match("*.mp4", "video.mp4"));
784        assert!(glob_match("*.mp4", ".mp4"));
785        assert!(!glob_match("*.mp4", "video.mov"));
786    }
787
788    #[test]
789    fn test_glob_match_multiple_stars() {
790        assert!(glob_match("*_*_v2.*", "clip_cam1_v2.mxf"));
791        assert!(!glob_match("*_*_v2.*", "clip_cam1_v1.mxf"));
792    }
793
794    // -----------------------------------------------------------------------
795    // WebhookTrigger
796    // -----------------------------------------------------------------------
797
798    #[test]
799    fn test_webhook_trigger_basic_match() {
800        let trigger =
801            WebhookTrigger::new("/hooks/ingest").with_required_field("event", "ingest.ready");
802
803        let body = serde_json::json!({"event": "ingest.ready", "asset": "clip-001"});
804        assert!(trigger.body_matches(&body));
805    }
806
807    #[test]
808    fn test_webhook_trigger_field_mismatch() {
809        let trigger =
810            WebhookTrigger::new("/hooks/ingest").with_required_field("event", "ingest.ready");
811
812        let body = serde_json::json!({"event": "transcode.done"});
813        assert!(!trigger.body_matches(&body));
814    }
815
816    #[test]
817    fn test_webhook_trigger_missing_field() {
818        let trigger =
819            WebhookTrigger::new("/hooks/ingest").with_required_field("event", "ingest.ready");
820
821        let body = serde_json::json!({"other": "value"});
822        assert!(!trigger.body_matches(&body));
823    }
824
825    #[test]
826    fn test_webhook_trigger_no_secret_always_valid() {
827        let trigger = WebhookTrigger::new("/hooks/test");
828        assert!(trigger.verify_signature(b"payload", ""));
829        assert!(trigger.verify_signature(b"payload", "any_sig"));
830    }
831
832    #[test]
833    fn test_webhook_trigger_with_secret_valid() {
834        let trigger = WebhookTrigger::new("/hooks/test").with_secret("mysecret");
835        let payload = b"hello world";
836        // Compute expected HMAC
837        let expected = hmac_sha256(b"mysecret", payload);
838        assert!(trigger.verify_signature(payload, &expected));
839    }
840
841    #[test]
842    fn test_webhook_trigger_with_secret_invalid() {
843        let trigger = WebhookTrigger::new("/hooks/test").with_secret("mysecret");
844        assert!(!trigger.verify_signature(b"payload", "wrong_signature"));
845    }
846
847    #[test]
848    fn test_webhook_trigger_builder_chain() {
849        let trigger = WebhookTrigger::new("/hooks/media")
850            .with_secret("s3cr3t")
851            .with_required_field("type", "video")
852            .with_description("Video ingest webhook");
853
854        assert_eq!(trigger.path, "/hooks/media");
855        assert!(trigger.secret.is_some());
856        assert_eq!(trigger.required_fields.len(), 1);
857        assert_eq!(trigger.description, "Video ingest webhook");
858    }
859
860    // -----------------------------------------------------------------------
861    // WebhookRouter
862    // -----------------------------------------------------------------------
863
864    #[test]
865    fn test_webhook_router_basic_route() {
866        let mut router = WebhookRouter::new();
867        let trigger = WebhookTrigger::new("/hooks/ingest").with_required_field("event", "ready");
868        router.register("wf-ingest", trigger);
869
870        let body = serde_json::json!({"event": "ready"});
871        let body_bytes = serde_json::to_vec(&body).expect("serialize");
872        let triggered = router.route("/hooks/ingest", &body_bytes, &body, None);
873        assert_eq!(triggered.len(), 1);
874        assert_eq!(triggered[0], "wf-ingest");
875    }
876
877    #[test]
878    fn test_webhook_router_path_no_match() {
879        let mut router = WebhookRouter::new();
880        router.register("wf-1", WebhookTrigger::new("/hooks/ingest"));
881
882        let body = serde_json::json!({});
883        let body_bytes = serde_json::to_vec(&body).expect("serialize");
884        let triggered = router.route("/hooks/other", &body_bytes, &body, None);
885        assert!(triggered.is_empty());
886    }
887
888    #[test]
889    fn test_webhook_router_multiple_workflows() {
890        let mut router = WebhookRouter::new();
891        router.register("wf-a", WebhookTrigger::new("/hooks/event"));
892        router.register("wf-b", WebhookTrigger::new("/hooks/event"));
893
894        let body = serde_json::json!({});
895        let body_bytes = serde_json::to_vec(&body).expect("serialize");
896        let mut triggered = router.route("/hooks/event", &body_bytes, &body, None);
897        triggered.sort();
898        assert_eq!(triggered.len(), 2);
899    }
900
901    #[test]
902    fn test_webhook_router_remove() {
903        let mut router = WebhookRouter::new();
904        router.register("wf-1", WebhookTrigger::new("/hooks/test"));
905        router.remove("wf-1");
906
907        let body = serde_json::json!({});
908        let body_bytes = serde_json::to_vec(&body).expect("serialize");
909        let triggered = router.route("/hooks/test", &body_bytes, &body, None);
910        assert!(triggered.is_empty());
911    }
912
913    #[test]
914    fn test_webhook_router_trigger_count() {
915        let mut router = WebhookRouter::new();
916        router.register("wf-1", WebhookTrigger::new("/hooks/a"));
917        router.register("wf-1", WebhookTrigger::new("/hooks/b"));
918        router.register("wf-2", WebhookTrigger::new("/hooks/c"));
919        assert_eq!(router.trigger_count(), 3);
920    }
921
922    #[test]
923    fn test_sha256_known_value() {
924        // SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
925        let hash = sha256(b"");
926        let hex: String = hash.iter().fold(String::new(), |mut s, b| {
927            s.push_str(&format!("{b:02x}"));
928            s
929        });
930        assert_eq!(
931            hex,
932            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
933        );
934    }
935
936    #[test]
937    fn test_hmac_sha256_non_empty() {
938        // Just verify it produces a 64-char hex string
939        let mac = hmac_sha256(b"key", b"message");
940        assert_eq!(mac.len(), 64);
941        assert!(mac.chars().all(|c| c.is_ascii_hexdigit()));
942    }
943}
944
945// =============================================================================
946// WebhookPayload — inbound HTTP payload received by WebhookTriggerServer
947// =============================================================================
948
949/// An inbound HTTP POST payload received by the webhook trigger server.
950#[derive(Debug, Clone)]
951pub struct WebhookPayload {
952    /// HTTP headers as lowercase-key map.
953    pub headers: HashMap<String, String>,
954    /// Raw request body bytes.
955    pub body: Vec<u8>,
956    /// Unix timestamp (milliseconds) at which the request was received.
957    pub timestamp: u64,
958    /// URL path of the request (e.g. `"/webhooks/ingest-ready"`).
959    pub path: String,
960}
961
962impl WebhookPayload {
963    /// Create a new `WebhookPayload`.
964    #[must_use]
965    pub fn new(
966        path: impl Into<String>,
967        headers: HashMap<String, String>,
968        body: Vec<u8>,
969        timestamp: u64,
970    ) -> Self {
971        Self {
972            headers,
973            body,
974            timestamp,
975            path: path.into(),
976        }
977    }
978
979    /// Look up a header value using a **case-insensitive** key.
980    ///
981    /// Returns `None` if the header is absent.
982    #[must_use]
983    pub fn header(&self, key: &str) -> Option<&str> {
984        let key_lower = key.to_ascii_lowercase();
985        self.headers
986            .iter()
987            .find(|(k, _)| k.to_ascii_lowercase() == key_lower)
988            .map(|(_, v)| v.as_str())
989    }
990
991    /// Return the body as a UTF-8 `&str`, or `None` if the body is not valid UTF-8.
992    #[must_use]
993    pub fn body_str(&self) -> Option<&str> {
994        std::str::from_utf8(&self.body).ok()
995    }
996
997    /// Parse the body as JSON.
998    ///
999    /// # Errors
1000    ///
1001    /// Returns a `serde_json::Error` if the body is not valid JSON.
1002    pub fn body_json(&self) -> Result<serde_json::Value, serde_json::Error>
1003    where
1004        Self: Sized,
1005    {
1006        serde_json::from_slice(&self.body)
1007    }
1008}
1009
1010// =============================================================================
1011// WebhookTriggerServer — pure-Rust HTTP/1.1 server for inbound webhooks
1012// =============================================================================
1013
1014/// A minimal pure-Rust HTTP/1.1 server that listens for inbound HTTP POST
1015/// requests and dispatches them to registered [`WebhookTrigger`] handlers via
1016/// a [`WebhookRouter`].
1017///
1018/// Uses [`std::net::TcpListener`] — no async runtime is required.
1019/// Each accepted connection is handled synchronously (blocking I/O).
1020///
1021/// # Security note
1022///
1023/// This is designed for low-volume internal webhook reception.  Production
1024/// deployments should sit behind a TLS-terminating reverse proxy.
1025#[cfg(not(target_arch = "wasm32"))]
1026pub struct WebhookTriggerServer {
1027    /// Address to bind to (e.g. `"127.0.0.1:0"` for an ephemeral port).
1028    bind_addr: String,
1029    /// Shared, thread-safe router.
1030    router: std::sync::Arc<std::sync::RwLock<WebhookRouter>>,
1031    /// Atomic flag used to signal the accept loop to stop.
1032    running: std::sync::Arc<std::sync::atomic::AtomicBool>,
1033}
1034
1035#[cfg(not(target_arch = "wasm32"))]
1036impl WebhookTriggerServer {
1037    /// Create a new server bound to `bind_addr`.
1038    ///
1039    /// The server does not start listening until [`start`] is called.
1040    ///
1041    /// [`start`]: WebhookTriggerServer::start
1042    #[must_use]
1043    pub fn new(bind_addr: impl Into<String>) -> Self {
1044        Self {
1045            bind_addr: bind_addr.into(),
1046            router: std::sync::Arc::new(std::sync::RwLock::new(WebhookRouter::new())),
1047            running: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1048        }
1049    }
1050
1051    /// Register a webhook trigger for a workflow.
1052    pub fn register_trigger(&self, workflow_id: impl Into<String>, trigger: WebhookTrigger) {
1053        if let Ok(mut r) = self.router.write() {
1054            r.register(workflow_id, trigger);
1055        }
1056    }
1057
1058    /// Return `true` if the accept loop is currently active.
1059    #[must_use]
1060    pub fn is_running(&self) -> bool {
1061        self.running.load(std::sync::atomic::Ordering::SeqCst)
1062    }
1063
1064    /// Start the server, binding to the configured address.
1065    ///
1066    /// Spawns a background thread that accepts connections.  The
1067    /// [`std::thread::JoinHandle`] is returned so the caller can optionally
1068    /// join the thread.
1069    ///
1070    /// # Errors
1071    ///
1072    /// Returns `std::io::Error` if the TCP socket cannot be bound.
1073    pub fn start(&self) -> Result<std::thread::JoinHandle<()>, std::io::Error> {
1074        use std::net::TcpListener;
1075        use std::sync::atomic::Ordering;
1076
1077        let listener = TcpListener::bind(&self.bind_addr)?;
1078        // Set a short read timeout so the accept loop can check the `running`
1079        // flag periodically even when no connections arrive.
1080        listener.set_nonblocking(true)?;
1081
1082        let router = std::sync::Arc::clone(&self.router);
1083        let running = std::sync::Arc::clone(&self.running);
1084        running.store(true, Ordering::SeqCst);
1085
1086        let handle = std::thread::spawn(move || {
1087            while running.load(Ordering::SeqCst) {
1088                match listener.accept() {
1089                    Ok((stream, _addr)) => {
1090                        let router_ref = std::sync::Arc::clone(&router);
1091                        // Handle each connection synchronously in-place.
1092                        // For a high-throughput server, spawn a thread per
1093                        // connection; here we keep it simple.
1094                        Self::handle_connection(stream, router_ref);
1095                    }
1096                    Err(ref e)
1097                        if e.kind() == std::io::ErrorKind::WouldBlock
1098                            || e.kind() == std::io::ErrorKind::TimedOut =>
1099                    {
1100                        // Non-blocking: no connection ready, spin.
1101                        std::thread::sleep(std::time::Duration::from_millis(1));
1102                    }
1103                    Err(_) => {
1104                        // Other errors (e.g. socket closed) → exit loop.
1105                        break;
1106                    }
1107                }
1108            }
1109            running.store(false, Ordering::SeqCst);
1110        });
1111
1112        Ok(handle)
1113    }
1114
1115    /// Stop the accept loop.  The background thread will exit on its next
1116    /// iteration.
1117    pub fn stop(&self) {
1118        self.running
1119            .store(false, std::sync::atomic::Ordering::SeqCst);
1120    }
1121
1122    /// Handle a single HTTP/1.1 connection: parse the request, route it, and
1123    /// send a response.
1124    ///
1125    /// Returns the list of workflow IDs that were triggered (for testing).
1126    pub fn handle_connection(
1127        mut stream: std::net::TcpStream,
1128        router: std::sync::Arc<std::sync::RwLock<WebhookRouter>>,
1129    ) -> Vec<String> {
1130        use std::io::{BufRead, BufReader, Read, Write};
1131
1132        // Accepted sockets inherit the non-blocking flag from the listener.
1133        // Switch to blocking so that BufReader reads complete without WouldBlock.
1134        let _ = stream.set_nonblocking(false);
1135
1136        // 1. Read the request line + headers.
1137        let mut reader = BufReader::new(&mut stream);
1138
1139        let mut request_line = String::new();
1140        if reader.read_line(&mut request_line).is_err() {
1141            let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n");
1142            return Vec::new();
1143        }
1144
1145        // Parse METHOD PATH HTTP/1.1
1146        let parts: Vec<&str> = request_line.trim().splitn(3, ' ').collect();
1147        if parts.len() < 2 {
1148            let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n");
1149            return Vec::new();
1150        }
1151        let path = parts[1].to_string();
1152
1153        // 2. Read headers until blank line.
1154        let mut headers: HashMap<String, String> = HashMap::new();
1155        loop {
1156            let mut line = String::new();
1157            match reader.read_line(&mut line) {
1158                Ok(0) | Err(_) => break,
1159                Ok(_) => {
1160                    let trimmed = line.trim_end_matches(['\r', '\n']);
1161                    if trimmed.is_empty() {
1162                        break; // end of headers
1163                    }
1164                    if let Some(colon_pos) = trimmed.find(':') {
1165                        let key = trimmed[..colon_pos].trim().to_ascii_lowercase();
1166                        let value = trimmed[colon_pos + 1..].trim().to_string();
1167                        headers.insert(key, value);
1168                    }
1169                }
1170            }
1171        }
1172
1173        // 3. Read body (Content-Length bytes).
1174        let content_length: usize = headers
1175            .get("content-length")
1176            .and_then(|v| v.parse().ok())
1177            .unwrap_or(0);
1178
1179        let mut body = vec![0u8; content_length];
1180        if content_length > 0 {
1181            if reader.read_exact(&mut body).is_err() {
1182                let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n");
1183                return Vec::new();
1184            }
1185        }
1186
1187        // 4. Extract optional HMAC signature.
1188        let signature = headers.get("x-hub-signature-256").map(String::as_str);
1189
1190        // 5. Parse body as JSON; fall back to empty object on parse failure.
1191        let body_json: serde_json::Value =
1192            serde_json::from_slice(&body).unwrap_or_else(|_| serde_json::json!({}));
1193
1194        // 6. Route to matching workflows.
1195        let triggered = router
1196            .read()
1197            .map(|r| r.route(&path, &body, &body_json, signature))
1198            .unwrap_or_default();
1199
1200        // 7. Send response.
1201        let status = if triggered.is_empty() {
1202            "404 Not Found"
1203        } else {
1204            "200 OK"
1205        };
1206        let resp = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\n\r\n");
1207        let _ = stream.write_all(resp.as_bytes());
1208
1209        triggered
1210    }
1211
1212    /// Return the actual bound address (useful when binding to port 0).
1213    ///
1214    /// # Errors
1215    ///
1216    /// Returns `std::io::Error` if the address cannot be determined.
1217    pub fn local_addr(&self) -> Result<std::net::SocketAddr, std::io::Error> {
1218        // We can only know the port after binding, so callers should use the
1219        // handle returned by `start()`.  This helper re-binds momentarily which
1220        // is not ideal in production, but is sufficient for test introspection.
1221        std::net::TcpListener::bind(&self.bind_addr).and_then(|l| l.local_addr())
1222    }
1223}
1224
1225// =============================================================================
1226// WebhookTriggerServer tests
1227// =============================================================================
1228
1229#[cfg(test)]
1230mod server_tests {
1231    use super::*;
1232
1233    // ── WebhookPayload ────────────────────────────────────────────────────────
1234
1235    #[test]
1236    fn test_webhook_payload_construction() {
1237        let mut hdrs = HashMap::new();
1238        hdrs.insert("content-type".to_string(), "application/json".to_string());
1239        let body = b"{\"event\":\"push\"}".to_vec();
1240        let payload = WebhookPayload::new("/hooks/push", hdrs, body.clone(), 1_700_000_000_000);
1241
1242        assert_eq!(payload.path, "/hooks/push");
1243        assert_eq!(payload.body, body);
1244        assert_eq!(payload.timestamp, 1_700_000_000_000);
1245    }
1246
1247    #[test]
1248    fn test_webhook_payload_header_case_insensitive() {
1249        let mut hdrs = HashMap::new();
1250        hdrs.insert("X-Hub-Signature-256".to_string(), "abc123".to_string());
1251        let payload = WebhookPayload::new("/", hdrs, Vec::new(), 0);
1252
1253        assert_eq!(payload.header("x-hub-signature-256"), Some("abc123"));
1254        assert_eq!(payload.header("X-HUB-SIGNATURE-256"), Some("abc123"));
1255        assert_eq!(payload.header("X-Hub-Signature-256"), Some("abc123"));
1256        assert!(payload.header("missing-header").is_none());
1257    }
1258
1259    #[test]
1260    fn test_webhook_payload_body_str_valid_utf8() {
1261        let body = b"hello world".to_vec();
1262        let payload = WebhookPayload::new("/", HashMap::new(), body, 0);
1263        assert_eq!(payload.body_str(), Some("hello world"));
1264    }
1265
1266    #[test]
1267    fn test_webhook_payload_body_str_invalid_utf8() {
1268        let body = vec![0xFF, 0xFE]; // invalid UTF-8
1269        let payload = WebhookPayload::new("/", HashMap::new(), body, 0);
1270        assert!(payload.body_str().is_none());
1271    }
1272
1273    #[test]
1274    fn test_webhook_payload_body_json_valid() {
1275        let body = b"{\"key\":42}".to_vec();
1276        let payload = WebhookPayload::new("/", HashMap::new(), body, 0);
1277        let json = payload.body_json().expect("valid JSON");
1278        assert_eq!(json["key"], 42);
1279    }
1280
1281    #[test]
1282    fn test_webhook_payload_body_json_invalid() {
1283        let body = b"not json".to_vec();
1284        let payload = WebhookPayload::new("/", HashMap::new(), body, 0);
1285        assert!(payload.body_json().is_err());
1286    }
1287
1288    #[test]
1289    fn test_webhook_payload_empty_body() {
1290        let payload = WebhookPayload::new("/", HashMap::new(), Vec::new(), 0);
1291        assert_eq!(payload.body_str(), Some(""));
1292        assert!(payload.body_json().is_err()); // empty is not valid JSON
1293    }
1294
1295    // ── WebhookTriggerServer — construction & state ───────────────────────────
1296
1297    #[cfg(not(target_arch = "wasm32"))]
1298    #[test]
1299    fn test_server_new_not_running() {
1300        let server = WebhookTriggerServer::new("127.0.0.1:0");
1301        assert!(!server.is_running());
1302    }
1303
1304    #[cfg(not(target_arch = "wasm32"))]
1305    #[test]
1306    fn test_server_register_trigger() {
1307        let server = WebhookTriggerServer::new("127.0.0.1:0");
1308        server.register_trigger("wf-1", WebhookTrigger::new("/hooks/test"));
1309        // Verify the trigger is stored in the router
1310        let router_guard = server.router.read().expect("read lock");
1311        assert_eq!(router_guard.trigger_count(), 1);
1312    }
1313
1314    #[cfg(not(target_arch = "wasm32"))]
1315    #[test]
1316    fn test_server_register_multiple_triggers() {
1317        let server = WebhookTriggerServer::new("127.0.0.1:0");
1318        server.register_trigger("wf-1", WebhookTrigger::new("/hooks/a"));
1319        server.register_trigger("wf-1", WebhookTrigger::new("/hooks/b"));
1320        server.register_trigger("wf-2", WebhookTrigger::new("/hooks/c"));
1321        let router_guard = server.router.read().expect("read lock");
1322        assert_eq!(router_guard.trigger_count(), 3);
1323    }
1324
1325    // ── Integration: start server, send POST, verify triggered ───────────────
1326
1327    #[cfg(not(target_arch = "wasm32"))]
1328    #[test]
1329    fn test_server_start_stop() {
1330        use std::net::TcpListener;
1331
1332        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1333        let port = listener.local_addr().expect("local addr").port();
1334        drop(listener);
1335
1336        let server = WebhookTriggerServer::new(format!("127.0.0.1:{port}"));
1337        server.register_trigger("wf-webhook", WebhookTrigger::new("/hooks/deploy"));
1338
1339        let handle = server.start().expect("start server");
1340        assert!(server.is_running());
1341
1342        server.stop();
1343        // Give the thread a moment to exit.
1344        std::thread::sleep(std::time::Duration::from_millis(50));
1345        let _ = handle.join();
1346        assert!(!server.is_running());
1347    }
1348
1349    #[cfg(not(target_arch = "wasm32"))]
1350    #[test]
1351    fn test_server_receives_post_triggers_workflow() {
1352        use std::io::Write;
1353        use std::net::{TcpListener, TcpStream};
1354
1355        // Bind to a free port first to learn the port number.
1356        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1357        let port = listener.local_addr().expect("local addr").port();
1358        drop(listener);
1359
1360        let server = WebhookTriggerServer::new(format!("127.0.0.1:{port}"));
1361        server.register_trigger("my-workflow", WebhookTrigger::new("/hooks/push"));
1362
1363        let _handle = server.start().expect("start server");
1364        // Brief sleep to let the accept loop spin up.
1365        std::thread::sleep(std::time::Duration::from_millis(30));
1366
1367        // Send a minimal HTTP POST.
1368        let body = b"{}";
1369        let request = format!(
1370            "POST /hooks/push HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\n\r\n",
1371            body.len()
1372        );
1373
1374        let mut conn = TcpStream::connect(format!("127.0.0.1:{port}")).expect("connect");
1375        conn.write_all(request.as_bytes()).expect("write request");
1376        conn.write_all(body).expect("write body");
1377
1378        // Read the response.
1379        let mut resp_buf = [0u8; 256];
1380        let n = {
1381            use std::io::Read;
1382            conn.read(&mut resp_buf).unwrap_or(0)
1383        };
1384        let resp_str = std::str::from_utf8(&resp_buf[..n]).unwrap_or("");
1385        assert!(
1386            resp_str.starts_with("HTTP/1.1 200"),
1387            "expected 200, got: {resp_str}"
1388        );
1389
1390        server.stop();
1391    }
1392
1393    #[cfg(not(target_arch = "wasm32"))]
1394    #[test]
1395    fn test_server_wrong_path_returns_404() {
1396        use std::io::Write;
1397        use std::net::{TcpListener, TcpStream};
1398
1399        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1400        let port = listener.local_addr().expect("local addr").port();
1401        drop(listener);
1402
1403        let server = WebhookTriggerServer::new(format!("127.0.0.1:{port}"));
1404        server.register_trigger("wf-x", WebhookTrigger::new("/hooks/correct"));
1405
1406        let _handle = server.start().expect("start");
1407        std::thread::sleep(std::time::Duration::from_millis(30));
1408
1409        let body = b"{}";
1410        let request = format!(
1411            "POST /hooks/wrong HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\n\r\n",
1412            body.len()
1413        );
1414        let mut conn = TcpStream::connect(format!("127.0.0.1:{port}")).expect("connect");
1415        conn.write_all(request.as_bytes()).expect("write");
1416        conn.write_all(body).expect("write body");
1417
1418        let mut buf = [0u8; 256];
1419        let n = {
1420            use std::io::Read;
1421            conn.read(&mut buf).unwrap_or(0)
1422        };
1423        let resp = std::str::from_utf8(&buf[..n]).unwrap_or("");
1424        assert!(
1425            resp.starts_with("HTTP/1.1 404"),
1426            "expected 404, got: {resp}"
1427        );
1428
1429        server.stop();
1430    }
1431
1432    #[cfg(not(target_arch = "wasm32"))]
1433    #[test]
1434    fn test_server_signature_verification_accepted() {
1435        use std::io::Write;
1436        use std::net::{TcpListener, TcpStream};
1437
1438        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1439        let port = listener.local_addr().expect("local addr").port();
1440        drop(listener);
1441
1442        let secret = "s3cr3t";
1443        let server = WebhookTriggerServer::new(format!("127.0.0.1:{port}"));
1444        server.register_trigger(
1445            "secure-wf",
1446            WebhookTrigger::new("/hooks/secure").with_secret(secret),
1447        );
1448
1449        let _handle = server.start().expect("start");
1450        std::thread::sleep(std::time::Duration::from_millis(30));
1451
1452        let body = b"{}";
1453        // Compute correct HMAC-SHA256 signature via the existing pure-Rust implementation.
1454        // We replicate the public interface via WebhookTrigger::verify_signature logic.
1455        let sig = {
1456            // Use a temporary trigger to call compute_signature path.
1457            let _dummy_trigger = WebhookTrigger::new("/").with_secret(secret);
1458            // We manually compute HMAC-SHA256 using the private fn by a round-trip:
1459            // verify_signature returns true when sig matches → we need the correct value.
1460            // Since we can't call private fn directly, use the outbound WebhookNotifier.
1461            use crate::webhook::{WebhookConfig, WebhookNotifier};
1462            let notifier = WebhookNotifier::new(WebhookConfig {
1463                url: String::new(),
1464                secret: Some(secret.to_string()),
1465                events: Vec::new(),
1466                max_retries: 0,
1467                timeout_ms: 0,
1468            });
1469            notifier
1470                .compute_signature(std::str::from_utf8(body).expect("utf8"))
1471                .expect("sig")
1472        };
1473
1474        let request = format!(
1475            "POST /hooks/secure HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\nX-Hub-Signature-256: {}\r\n\r\n",
1476            body.len(),
1477            sig
1478        );
1479        let mut conn = TcpStream::connect(format!("127.0.0.1:{port}")).expect("connect");
1480        conn.write_all(request.as_bytes()).expect("write");
1481        conn.write_all(body).expect("write body");
1482
1483        let mut buf = [0u8; 256];
1484        let n = {
1485            use std::io::Read;
1486            conn.read(&mut buf).unwrap_or(0)
1487        };
1488        let resp = std::str::from_utf8(&buf[..n]).unwrap_or("");
1489        assert!(
1490            resp.starts_with("HTTP/1.1 200"),
1491            "expected 200 with valid sig, got: {resp}"
1492        );
1493
1494        server.stop();
1495    }
1496
1497    #[cfg(not(target_arch = "wasm32"))]
1498    #[test]
1499    fn test_server_bad_signature_returns_404() {
1500        use std::io::Write;
1501        use std::net::{TcpListener, TcpStream};
1502
1503        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1504        let port = listener.local_addr().expect("local addr").port();
1505        drop(listener);
1506
1507        let server = WebhookTriggerServer::new(format!("127.0.0.1:{port}"));
1508        server.register_trigger(
1509            "secure-wf",
1510            WebhookTrigger::new("/hooks/secure").with_secret("correct-secret"),
1511        );
1512
1513        let _handle = server.start().expect("start");
1514        std::thread::sleep(std::time::Duration::from_millis(30));
1515
1516        let body = b"{}";
1517        let bad_sig = "a".repeat(64); // wrong 64-char hex string
1518        let request = format!(
1519            "POST /hooks/secure HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\nX-Hub-Signature-256: {bad_sig}\r\n\r\n",
1520            body.len()
1521        );
1522        let mut conn = TcpStream::connect(format!("127.0.0.1:{port}")).expect("connect");
1523        conn.write_all(request.as_bytes()).expect("write");
1524        conn.write_all(body).expect("write body");
1525
1526        let mut buf = [0u8; 256];
1527        let n = {
1528            use std::io::Read;
1529            conn.read(&mut buf).unwrap_or(0)
1530        };
1531        let resp = std::str::from_utf8(&buf[..n]).unwrap_or("");
1532        assert!(
1533            resp.starts_with("HTTP/1.1 404"),
1534            "bad sig should get 404, got: {resp}"
1535        );
1536
1537        server.stop();
1538    }
1539
1540    #[cfg(not(target_arch = "wasm32"))]
1541    #[test]
1542    fn test_server_body_field_matching() {
1543        use std::io::Write;
1544        use std::net::{TcpListener, TcpStream};
1545
1546        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1547        let port = listener.local_addr().expect("local addr").port();
1548        drop(listener);
1549
1550        let server = WebhookTriggerServer::new(format!("127.0.0.1:{port}"));
1551        server.register_trigger(
1552            "wf-prod",
1553            WebhookTrigger::new("/hooks/deploy").with_required_field("environment", "production"),
1554        );
1555
1556        let _handle = server.start().expect("start");
1557        std::thread::sleep(std::time::Duration::from_millis(30));
1558
1559        // Matching body — send headers + body as a single write to avoid
1560        // a race where the server closes the connection before the second write.
1561        let body = br#"{"environment":"production"}"#;
1562        let mut full_request = format!(
1563            "POST /hooks/deploy HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
1564            body.len()
1565        ).into_bytes();
1566        full_request.extend_from_slice(body);
1567
1568        let mut conn = TcpStream::connect(format!("127.0.0.1:{port}")).expect("connect");
1569        conn.write_all(&full_request).expect("write full request");
1570        // Shut down the write side so the server knows we are done sending.
1571        conn.shutdown(std::net::Shutdown::Write).ok();
1572
1573        let mut buf = [0u8; 256];
1574        let n = {
1575            use std::io::Read;
1576            conn.read(&mut buf).unwrap_or(0)
1577        };
1578        let resp = std::str::from_utf8(&buf[..n]).unwrap_or("");
1579        assert!(
1580            resp.starts_with("HTTP/1.1 200"),
1581            "matching body should trigger: {resp}"
1582        );
1583
1584        server.stop();
1585    }
1586}