Skip to main content

mockforge_http/management/
mod.rs

1/// Management API for MockForge
2///
3/// Provides REST endpoints for controlling mocks, server configuration,
4/// and integration with developer tools (VS Code extension, CI/CD, etc.)
5mod ai_gen;
6mod chaos_admin;
7mod conformance;
8mod health;
9mod import_export;
10mod migration;
11mod mocks;
12mod protocols;
13mod proxy;
14mod rule_explanations;
15mod traffic_to_openapi;
16
17// `ai_gen.rs` was split into four topic files under #656; the route
18// wiring below pulls handlers from each via these glob re-exports.
19pub use ai_gen::*;
20pub use chaos_admin::*;
21pub(crate) use conformance::{clear_conformance_violations, get_conformance_violations};
22pub use health::*;
23pub use import_export::*;
24pub use proxy::{BodyTransformRequest, ProxyRuleRequest, ProxyRuleResponse};
25pub use rule_explanations::*;
26pub use traffic_to_openapi::*;
27
28use axum::{
29    body::Body,
30    extract::State,
31    http::{HeaderName, HeaderValue, Request, StatusCode},
32    response::{IntoResponse, Response},
33    routing::{delete, get, post, put},
34    Router,
35};
36use mockforge_openapi::OpenApiSpec;
37use mockforge_proxy::config::ProxyConfig;
38use serde::{Deserialize, Serialize};
39use std::sync::Arc;
40use tokio::sync::{broadcast, RwLock};
41
42/// Default broadcast channel capacity for message events
43#[cfg(any(feature = "mqtt", feature = "kafka"))]
44const DEFAULT_MESSAGE_BROADCAST_CAPACITY: usize = 1000;
45
46/// Get the broadcast channel capacity from environment or use default
47#[cfg(any(feature = "mqtt", feature = "kafka"))]
48fn get_message_broadcast_capacity() -> usize {
49    std::env::var("MOCKFORGE_MESSAGE_BROADCAST_CAPACITY")
50        .ok()
51        .and_then(|s| s.parse().ok())
52        .unwrap_or(DEFAULT_MESSAGE_BROADCAST_CAPACITY)
53}
54
55/// Message event types for real-time monitoring
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(tag = "protocol", content = "data")]
58#[serde(rename_all = "lowercase")]
59pub enum MessageEvent {
60    #[cfg(feature = "mqtt")]
61    /// MQTT message event
62    Mqtt(MqttMessageEvent),
63    #[cfg(feature = "kafka")]
64    /// Kafka message event
65    Kafka(KafkaMessageEvent),
66}
67
68#[cfg(feature = "mqtt")]
69/// MQTT message event for real-time monitoring
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct MqttMessageEvent {
72    /// MQTT topic name
73    pub topic: String,
74    /// Message payload content
75    pub payload: String,
76    /// Quality of Service level (0, 1, or 2)
77    pub qos: u8,
78    /// Whether the message is retained
79    pub retain: bool,
80    /// RFC3339 formatted timestamp
81    pub timestamp: String,
82}
83
84#[cfg(feature = "kafka")]
85#[allow(missing_docs)]
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct KafkaMessageEvent {
88    pub topic: String,
89    pub key: Option<String>,
90    pub value: String,
91    pub partition: i32,
92    pub offset: i64,
93    pub headers: Option<std::collections::HashMap<String, String>>,
94    pub timestamp: String,
95}
96
97/// Mock configuration representation
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct MockConfig {
100    /// Unique identifier for the mock. Auto-generated by `create_mock` when
101    /// omitted on input — clients can POST without an `id` and the server
102    /// will assign one.
103    #[serde(default, skip_serializing_if = "String::is_empty")]
104    pub id: String,
105    /// Human-readable name for the mock. Optional on input; defaults to an
106    /// empty string (consumers can leave it blank when programmatically
107    /// creating mocks via the management API).
108    #[serde(default)]
109    pub name: String,
110    /// HTTP method (GET, POST, etc.)
111    pub method: String,
112    /// API path pattern to match
113    pub path: String,
114    /// Response configuration
115    pub response: MockResponse,
116    /// Whether this mock is currently enabled
117    #[serde(default = "default_true")]
118    pub enabled: bool,
119    /// Optional latency to inject in milliseconds
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub latency_ms: Option<u64>,
122    /// Optional HTTP status code override
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub status_code: Option<u16>,
125    /// Request matching criteria (headers, query params, body patterns)
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub request_match: Option<RequestMatchCriteria>,
128    /// Priority for mock ordering (higher priority mocks are matched first)
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub priority: Option<i32>,
131    /// Scenario name for stateful mocking
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub scenario: Option<String>,
134    /// Required scenario state for this mock to be active
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub required_scenario_state: Option<String>,
137    /// New scenario state after this mock is matched
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub new_scenario_state: Option<String>,
140}
141
142fn default_true() -> bool {
143    true
144}
145
146/// Mock response configuration
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct MockResponse {
149    /// Response body as JSON
150    pub body: serde_json::Value,
151    /// Optional custom response headers
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub headers: Option<std::collections::HashMap<String, String>>,
154}
155
156/// Request matching criteria for advanced request matching
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158pub struct RequestMatchCriteria {
159    /// Headers that must be present and match (case-insensitive header names)
160    #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
161    pub headers: std::collections::HashMap<String, String>,
162    /// Query parameters that must be present and match
163    #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
164    pub query_params: std::collections::HashMap<String, String>,
165    /// Request body pattern (supports exact match or regex)
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub body_pattern: Option<String>,
168    /// JSONPath expression for JSON body matching
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub json_path: Option<String>,
171    /// XPath expression for XML body matching
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub xpath: Option<String>,
174    /// Custom matcher expression (e.g., "headers.content-type == \"application/json\"")
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub custom_matcher: Option<String>,
177}
178
179/// Check if a request matches the given mock configuration
180///
181/// This function implements comprehensive request matching including:
182/// - Method and path matching
183/// - Header matching (with regex support)
184/// - Query parameter matching
185/// - Body pattern matching (exact, regex, JSONPath, XPath)
186/// - Custom matcher expressions
187pub fn mock_matches_request(
188    mock: &MockConfig,
189    method: &str,
190    path: &str,
191    headers: &std::collections::HashMap<String, String>,
192    query_params: &std::collections::HashMap<String, String>,
193    body: Option<&[u8]>,
194) -> bool {
195    use regex::Regex;
196
197    // Check if mock is enabled
198    if !mock.enabled {
199        return false;
200    }
201
202    // Check method (case-insensitive)
203    if mock.method.to_uppercase() != method.to_uppercase() {
204        return false;
205    }
206
207    // Check path pattern (supports wildcards and path parameters)
208    if !path_matches_pattern(&mock.path, path) {
209        return false;
210    }
211
212    // Check request matching criteria if present
213    if let Some(criteria) = &mock.request_match {
214        // Check headers
215        for (key, expected_value) in &criteria.headers {
216            let header_key_lower = key.to_lowercase();
217            let found = headers.iter().find(|(k, _)| k.to_lowercase() == header_key_lower);
218
219            if let Some((_, actual_value)) = found {
220                // Try regex match first, then exact match
221                if let Ok(re) = Regex::new(expected_value) {
222                    if !re.is_match(actual_value) {
223                        return false;
224                    }
225                } else if actual_value != expected_value {
226                    return false;
227                }
228            } else {
229                return false; // Header not found
230            }
231        }
232
233        // Check query parameters
234        for (key, expected_value) in &criteria.query_params {
235            if let Some(actual_value) = query_params.get(key) {
236                if actual_value != expected_value {
237                    return false;
238                }
239            } else {
240                return false; // Query param not found
241            }
242        }
243
244        // Check body pattern
245        if let Some(pattern) = &criteria.body_pattern {
246            if let Some(body_bytes) = body {
247                let body_str = String::from_utf8_lossy(body_bytes);
248                // Try regex first, then exact match
249                if let Ok(re) = Regex::new(pattern) {
250                    if !re.is_match(&body_str) {
251                        return false;
252                    }
253                } else if body_str.as_ref() != pattern {
254                    return false;
255                }
256            } else {
257                return false; // Body required but not present
258            }
259        }
260
261        // Check JSONPath (simplified implementation)
262        if let Some(json_path) = &criteria.json_path {
263            if let Some(body_bytes) = body {
264                if let Ok(body_str) = std::str::from_utf8(body_bytes) {
265                    if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(body_str) {
266                        // Simple JSONPath check
267                        if !json_path_exists(&json_value, json_path) {
268                            return false;
269                        }
270                    }
271                }
272            }
273        }
274
275        // Check XPath (supports a focused subset)
276        if let Some(xpath) = &criteria.xpath {
277            if let Some(body_bytes) = body {
278                if let Ok(body_str) = std::str::from_utf8(body_bytes) {
279                    if !xml_xpath_exists(body_str, xpath) {
280                        return false;
281                    }
282                } else {
283                    return false;
284                }
285            } else {
286                return false; // Body required but not present
287            }
288        }
289
290        // Check custom matcher
291        if let Some(custom) = &criteria.custom_matcher {
292            if !evaluate_custom_matcher(custom, method, path, headers, query_params, body) {
293                return false;
294            }
295        }
296    }
297
298    true
299}
300
301/// Check if a path matches a pattern (supports wildcards and path parameters)
302fn path_matches_pattern(pattern: &str, path: &str) -> bool {
303    // Exact match
304    if pattern == path {
305        return true;
306    }
307
308    // Wildcard match
309    if pattern == "*" {
310        return true;
311    }
312
313    // Path parameter matching (e.g., /users/{id} matches /users/123)
314    let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
315    let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
316
317    if pattern_parts.len() != path_parts.len() {
318        // Check for wildcard patterns
319        if pattern.contains('*') {
320            return matches_wildcard_pattern(pattern, path);
321        }
322        return false;
323    }
324
325    for (pattern_part, path_part) in pattern_parts.iter().zip(path_parts.iter()) {
326        // Check for path parameters {param}
327        if pattern_part.starts_with('{') && pattern_part.ends_with('}') {
328            continue; // Matches any value
329        }
330
331        if pattern_part != path_part {
332            return false;
333        }
334    }
335
336    true
337}
338
339/// Check if path matches a wildcard pattern
340fn matches_wildcard_pattern(pattern: &str, path: &str) -> bool {
341    use regex::Regex;
342
343    // Convert pattern to regex
344    let regex_pattern = pattern.replace('*', ".*").replace('?', ".?");
345
346    if let Ok(re) = Regex::new(&format!("^{}$", regex_pattern)) {
347        return re.is_match(path);
348    }
349
350    false
351}
352
353/// Check if a JSONPath exists in a JSON value
354///
355/// Supports:
356/// - `$` — root element
357/// - `$.field.subfield` — nested object access
358/// - `$.items[0].name` — array index access
359/// - `$.items[*]` — array wildcard (checks array is non-empty)
360fn json_path_exists(json: &serde_json::Value, json_path: &str) -> bool {
361    let path = if json_path == "$" {
362        return true;
363    } else if let Some(p) = json_path.strip_prefix("$.") {
364        p
365    } else if let Some(p) = json_path.strip_prefix('$') {
366        p.strip_prefix('.').unwrap_or(p)
367    } else {
368        json_path
369    };
370
371    let mut current = json;
372    for segment in split_json_path_segments(path) {
373        match segment {
374            JsonPathSegment::Field(name) => {
375                if let Some(obj) = current.as_object() {
376                    if let Some(value) = obj.get(name) {
377                        current = value;
378                    } else {
379                        return false;
380                    }
381                } else {
382                    return false;
383                }
384            }
385            JsonPathSegment::Index(idx) => {
386                if let Some(arr) = current.as_array() {
387                    if let Some(value) = arr.get(idx) {
388                        current = value;
389                    } else {
390                        return false;
391                    }
392                } else {
393                    return false;
394                }
395            }
396            JsonPathSegment::Wildcard => {
397                if let Some(arr) = current.as_array() {
398                    return !arr.is_empty();
399                }
400                return false;
401            }
402        }
403    }
404    true
405}
406
407enum JsonPathSegment<'a> {
408    Field(&'a str),
409    Index(usize),
410    Wildcard,
411}
412
413/// Split a JSONPath (without the leading `$`) into segments
414fn split_json_path_segments(path: &str) -> Vec<JsonPathSegment<'_>> {
415    let mut segments = Vec::new();
416    for part in path.split('.') {
417        if part.is_empty() {
418            continue;
419        }
420        if let Some(bracket_start) = part.find('[') {
421            let field_name = &part[..bracket_start];
422            if !field_name.is_empty() {
423                segments.push(JsonPathSegment::Field(field_name));
424            }
425            let bracket_content = &part[bracket_start + 1..part.len() - 1];
426            if bracket_content == "*" {
427                segments.push(JsonPathSegment::Wildcard);
428            } else if let Ok(idx) = bracket_content.parse::<usize>() {
429                segments.push(JsonPathSegment::Index(idx));
430            }
431        } else {
432            segments.push(JsonPathSegment::Field(part));
433        }
434    }
435    segments
436}
437
438#[derive(Debug, Clone, PartialEq, Eq)]
439struct XPathSegment {
440    name: String,
441    text_equals: Option<String>,
442}
443
444fn parse_xpath_segment(segment: &str) -> Option<XPathSegment> {
445    if segment.is_empty() {
446        return None;
447    }
448
449    let trimmed = segment.trim();
450    if let Some(bracket_start) = trimmed.find('[') {
451        if !trimmed.ends_with(']') {
452            return None;
453        }
454
455        let name = trimmed[..bracket_start].trim();
456        let predicate = &trimmed[bracket_start + 1..trimmed.len() - 1];
457        let predicate = predicate.trim();
458
459        // Support simple predicate: [text()="value"] or [text()='value']
460        if let Some(raw) = predicate.strip_prefix("text()=") {
461            let raw = raw.trim();
462            if raw.len() >= 2
463                && ((raw.starts_with('"') && raw.ends_with('"'))
464                    || (raw.starts_with('\'') && raw.ends_with('\'')))
465            {
466                let text = raw[1..raw.len() - 1].to_string();
467                if !name.is_empty() {
468                    return Some(XPathSegment {
469                        name: name.to_string(),
470                        text_equals: Some(text),
471                    });
472                }
473            }
474        }
475
476        None
477    } else {
478        Some(XPathSegment {
479            name: trimmed.to_string(),
480            text_equals: None,
481        })
482    }
483}
484
485fn segment_matches(node: roxmltree::Node<'_, '_>, segment: &XPathSegment) -> bool {
486    if !node.is_element() {
487        return false;
488    }
489    if node.tag_name().name() != segment.name {
490        return false;
491    }
492    match &segment.text_equals {
493        Some(expected) => node.text().map(str::trim).unwrap_or_default() == expected,
494        None => true,
495    }
496}
497
498/// Check if an XPath expression matches an XML body.
499///
500/// Supported subset:
501/// - Absolute paths: `/root/child/item`
502/// - Descendant search: `//item` and `//parent/child`
503/// - Optional text predicate per segment: `item[text()="value"]`
504fn xml_xpath_exists(xml_body: &str, xpath: &str) -> bool {
505    let doc = match roxmltree::Document::parse(xml_body) {
506        Ok(doc) => doc,
507        Err(err) => {
508            tracing::warn!("Failed to parse XML for XPath matching: {}", err);
509            return false;
510        }
511    };
512
513    let expr = xpath.trim();
514    if expr.is_empty() {
515        return false;
516    }
517
518    let (is_descendant, path_str) = if let Some(rest) = expr.strip_prefix("//") {
519        (true, rest)
520    } else if let Some(rest) = expr.strip_prefix('/') {
521        (false, rest)
522    } else {
523        tracing::warn!("Unsupported XPath expression (must start with / or //): {}", expr);
524        return false;
525    };
526
527    let segments: Vec<XPathSegment> = path_str
528        .split('/')
529        .filter(|s| !s.trim().is_empty())
530        .filter_map(parse_xpath_segment)
531        .collect();
532
533    if segments.is_empty() {
534        return false;
535    }
536
537    if is_descendant {
538        let first = &segments[0];
539        for node in doc.descendants().filter(|n| segment_matches(*n, first)) {
540            let mut frontier = vec![node];
541            for segment in &segments[1..] {
542                let mut next_frontier = Vec::new();
543                for parent in &frontier {
544                    for child in parent.children().filter(|n| segment_matches(*n, segment)) {
545                        next_frontier.push(child);
546                    }
547                }
548                if next_frontier.is_empty() {
549                    frontier.clear();
550                    break;
551                }
552                frontier = next_frontier;
553            }
554            if !frontier.is_empty() {
555                return true;
556            }
557        }
558        false
559    } else {
560        let mut frontier = vec![doc.root_element()];
561        for (index, segment) in segments.iter().enumerate() {
562            let mut next_frontier = Vec::new();
563            for parent in &frontier {
564                if index == 0 {
565                    if segment_matches(*parent, segment) {
566                        next_frontier.push(*parent);
567                    }
568                    continue;
569                }
570                for child in parent.children().filter(|n| segment_matches(*n, segment)) {
571                    next_frontier.push(child);
572                }
573            }
574            if next_frontier.is_empty() {
575                return false;
576            }
577            frontier = next_frontier;
578        }
579        !frontier.is_empty()
580    }
581}
582
583/// Evaluate a custom matcher expression
584fn evaluate_custom_matcher(
585    expression: &str,
586    method: &str,
587    path: &str,
588    headers: &std::collections::HashMap<String, String>,
589    query_params: &std::collections::HashMap<String, String>,
590    body: Option<&[u8]>,
591) -> bool {
592    use regex::Regex;
593
594    let expr = expression.trim();
595
596    // Handle equality expressions (field == "value")
597    if expr.contains("==") {
598        let parts: Vec<&str> = expr.split("==").map(|s| s.trim()).collect();
599        if parts.len() != 2 {
600            return false;
601        }
602
603        let field = parts[0];
604        let expected_value = parts[1].trim_matches('"').trim_matches('\'');
605
606        match field {
607            "method" => method == expected_value,
608            "path" => path == expected_value,
609            _ if field.starts_with("headers.") => {
610                let header_name = &field[8..];
611                headers.get(header_name).map(|v| v == expected_value).unwrap_or(false)
612            }
613            _ if field.starts_with("query.") => {
614                let param_name = &field[6..];
615                query_params.get(param_name).map(|v| v == expected_value).unwrap_or(false)
616            }
617            _ => false,
618        }
619    }
620    // Handle regex match expressions (field =~ "pattern")
621    else if expr.contains("=~") {
622        let parts: Vec<&str> = expr.split("=~").map(|s| s.trim()).collect();
623        if parts.len() != 2 {
624            return false;
625        }
626
627        let field = parts[0];
628        let pattern = parts[1].trim_matches('"').trim_matches('\'');
629
630        if let Ok(re) = Regex::new(pattern) {
631            match field {
632                "method" => re.is_match(method),
633                "path" => re.is_match(path),
634                _ if field.starts_with("headers.") => {
635                    let header_name = &field[8..];
636                    headers.get(header_name).map(|v| re.is_match(v)).unwrap_or(false)
637                }
638                _ if field.starts_with("query.") => {
639                    let param_name = &field[6..];
640                    query_params.get(param_name).map(|v| re.is_match(v)).unwrap_or(false)
641                }
642                _ => false,
643            }
644        } else {
645            false
646        }
647    }
648    // Handle contains expressions (field contains "value")
649    else if expr.contains("contains") {
650        let parts: Vec<&str> = expr.split("contains").map(|s| s.trim()).collect();
651        if parts.len() != 2 {
652            return false;
653        }
654
655        let field = parts[0];
656        let search_value = parts[1].trim_matches('"').trim_matches('\'');
657
658        match field {
659            "path" => path.contains(search_value),
660            _ if field.starts_with("headers.") => {
661                let header_name = &field[8..];
662                headers.get(header_name).map(|v| v.contains(search_value)).unwrap_or(false)
663            }
664            _ if field.starts_with("body") => {
665                if let Some(body_bytes) = body {
666                    let body_str = String::from_utf8_lossy(body_bytes);
667                    body_str.contains(search_value)
668                } else {
669                    false
670                }
671            }
672            _ => false,
673        }
674    } else {
675        // Unknown expression format
676        tracing::warn!("Unknown custom matcher expression format: {}", expr);
677        false
678    }
679}
680
681/// Server statistics
682#[derive(Debug, Clone, Serialize, Deserialize)]
683pub struct ServerStats {
684    /// Server uptime in seconds
685    pub uptime_seconds: u64,
686    /// Total number of requests processed
687    pub total_requests: u64,
688    /// Number of active mock configurations
689    pub active_mocks: usize,
690    /// Number of currently enabled mocks
691    pub enabled_mocks: usize,
692    /// Number of registered API routes
693    pub registered_routes: usize,
694}
695
696/// Server configuration info
697#[derive(Debug, Clone, Serialize, Deserialize)]
698pub struct ServerConfig {
699    /// MockForge version string
700    pub version: String,
701    /// Server port number
702    pub port: u16,
703    /// Whether an OpenAPI spec is loaded
704    pub has_openapi_spec: bool,
705    /// Optional path to the OpenAPI spec file
706    #[serde(skip_serializing_if = "Option::is_none")]
707    pub spec_path: Option<String>,
708}
709
710/// Shared state for the management API
711#[derive(Clone)]
712pub struct ManagementState {
713    /// Collection of mock configurations
714    pub mocks: Arc<RwLock<Vec<MockConfig>>>,
715    /// Optional OpenAPI specification
716    pub spec: Option<Arc<OpenApiSpec>>,
717    /// Optional path to the OpenAPI spec file
718    pub spec_path: Option<String>,
719    /// Server port number
720    pub port: u16,
721    /// Server start time for uptime calculation
722    pub start_time: std::time::Instant,
723    /// Counter for total requests processed
724    pub request_counter: Arc<RwLock<u64>>,
725    /// Optional proxy configuration for migration pipeline
726    pub proxy_config: Option<Arc<RwLock<ProxyConfig>>>,
727    /// Optional SMTP registry for email mocking
728    #[cfg(feature = "smtp")]
729    pub smtp_registry: Option<Arc<mockforge_smtp::SmtpSpecRegistry>>,
730    /// Optional MQTT session manager (the live listener state) for the admin
731    /// API. Shared with the running listener so the admin reflects the clients
732    /// and topics actually being served (issue #730).
733    #[cfg(feature = "mqtt")]
734    pub mqtt_sessions: Option<Arc<mockforge_mqtt::SessionManager>>,
735    /// Optional Kafka broker for event streaming
736    #[cfg(feature = "kafka")]
737    pub kafka_broker: Option<Arc<mockforge_kafka::KafkaMockBroker>>,
738    /// Optional AMQP broker for exchange/queue mocking
739    #[cfg(feature = "amqp")]
740    pub amqp_broker: Option<Arc<mockforge_amqp::AmqpBroker>>,
741    /// Broadcast channel for message events (MQTT & Kafka)
742    #[cfg(any(feature = "mqtt", feature = "kafka"))]
743    pub message_events: Arc<broadcast::Sender<MessageEvent>>,
744    /// State machine manager for scenario state machines
745    pub state_machine_manager:
746        Arc<RwLock<mockforge_scenarios::state_machine::ScenarioStateMachineManager>>,
747    /// Optional WebSocket broadcast channel for real-time updates
748    pub ws_broadcast: Option<Arc<broadcast::Sender<crate::management_ws::MockEvent>>>,
749    /// Lifecycle hook registry for extensibility
750    pub lifecycle_hooks: Option<Arc<mockforge_core::lifecycle::LifecycleHookRegistry>>,
751    /// Rule explanations storage (in-memory for now)
752    pub rule_explanations: Arc<
753        RwLock<
754            std::collections::HashMap<
755                String,
756                mockforge_foundation::intelligent_behavior::rule_types::RuleExplanation,
757            >,
758        >,
759    >,
760    /// Optional chaos API state for chaos config management
761    #[cfg(feature = "chaos")]
762    pub chaos_api_state: Option<Arc<mockforge_chaos::api::ChaosApiState>>,
763    /// Optional server configuration for profile application
764    pub server_config: Option<Arc<RwLock<mockforge_core::config::ServerConfig>>>,
765    /// Issue #79 round 20 — the API base path the server was started with
766    /// (e.g. `/api`). When `MOCKFORGE_SHADOW_MODE=true`, the dynamic-mock
767    /// fallback returns 200 only for requests whose path is under this
768    /// prefix, and 404 otherwise. Pre-round-20, shadow returned 200 for
769    /// every unmatched path, including paths outside the configured
770    /// surface (e.g. `/api123/...` when the server is `/api`). Empty or
771    /// `None` means no prefix gate.
772    pub base_path: Option<String>,
773    /// Conformance testing state
774    #[cfg(feature = "conformance")]
775    pub conformance_state: crate::handlers::conformance::ConformanceState,
776}
777
778impl ManagementState {
779    /// Create a new management state
780    ///
781    /// # Arguments
782    /// * `spec` - Optional OpenAPI specification
783    /// * `spec_path` - Optional path to the OpenAPI spec file
784    /// * `port` - Server port number
785    pub fn new(spec: Option<Arc<OpenApiSpec>>, spec_path: Option<String>, port: u16) -> Self {
786        Self {
787            mocks: Arc::new(RwLock::new(Vec::new())),
788            spec,
789            spec_path,
790            port,
791            start_time: std::time::Instant::now(),
792            request_counter: Arc::new(RwLock::new(0)),
793            proxy_config: None,
794            #[cfg(feature = "smtp")]
795            smtp_registry: None,
796            #[cfg(feature = "mqtt")]
797            mqtt_sessions: None,
798            #[cfg(feature = "kafka")]
799            kafka_broker: None,
800            #[cfg(feature = "amqp")]
801            amqp_broker: None,
802            #[cfg(any(feature = "mqtt", feature = "kafka"))]
803            message_events: {
804                let capacity = get_message_broadcast_capacity();
805                let (tx, _) = broadcast::channel(capacity);
806                Arc::new(tx)
807            },
808            state_machine_manager: Arc::new(RwLock::new(
809                mockforge_scenarios::state_machine::ScenarioStateMachineManager::new(),
810            )),
811            ws_broadcast: None,
812            lifecycle_hooks: None,
813            rule_explanations: Arc::new(RwLock::new(std::collections::HashMap::new())),
814            #[cfg(feature = "chaos")]
815            chaos_api_state: None,
816            server_config: None,
817            // Round 20 — read the API base path from the env var the
818            // CLI sets for `--base-path`. Empty / `/` normalises to
819            // None (no prefix gate, pre-round-20 behaviour).
820            base_path: std::env::var("MOCKFORGE_API_BASE_PATH").ok().and_then(|p| {
821                let trimmed = p.trim_end_matches('/').to_string();
822                if trimmed.is_empty() || trimmed == "/" {
823                    None
824                } else {
825                    Some(trimmed)
826                }
827            }),
828            #[cfg(feature = "conformance")]
829            conformance_state: crate::handlers::conformance::ConformanceState::new(),
830        }
831    }
832
833    /// Set the API base path (e.g. `/api`). Required for shadow-mode's
834    /// base-path gate (#79 round 20). Normalises by trimming trailing
835    /// `/`, and treats empty / `/` as None.
836    pub fn with_base_path(mut self, base_path: Option<String>) -> Self {
837        self.base_path = base_path.and_then(|p| {
838            let trimmed = p.trim_end_matches('/').to_string();
839            if trimmed.is_empty() || trimmed == "/" {
840                None
841            } else {
842                Some(trimmed)
843            }
844        });
845        self
846    }
847
848    /// Add lifecycle hook registry to management state
849    pub fn with_lifecycle_hooks(
850        mut self,
851        hooks: Arc<mockforge_core::lifecycle::LifecycleHookRegistry>,
852    ) -> Self {
853        self.lifecycle_hooks = Some(hooks);
854        self
855    }
856
857    /// Add WebSocket broadcast channel to management state
858    pub fn with_ws_broadcast(
859        mut self,
860        ws_broadcast: Arc<broadcast::Sender<crate::management_ws::MockEvent>>,
861    ) -> Self {
862        self.ws_broadcast = Some(ws_broadcast);
863        self
864    }
865
866    /// Add proxy configuration to management state
867    pub fn with_proxy_config(mut self, proxy_config: Arc<RwLock<ProxyConfig>>) -> Self {
868        self.proxy_config = Some(proxy_config);
869        self
870    }
871
872    #[cfg(feature = "smtp")]
873    /// Add SMTP registry to management state
874    pub fn with_smtp_registry(
875        mut self,
876        smtp_registry: Arc<mockforge_smtp::SmtpSpecRegistry>,
877    ) -> Self {
878        self.smtp_registry = Some(smtp_registry);
879        self
880    }
881
882    #[cfg(feature = "mqtt")]
883    /// Add the MQTT session manager (live listener state) to management state
884    pub fn with_mqtt_sessions(
885        mut self,
886        mqtt_sessions: Arc<mockforge_mqtt::SessionManager>,
887    ) -> Self {
888        self.mqtt_sessions = Some(mqtt_sessions);
889        self
890    }
891
892    #[cfg(feature = "kafka")]
893    /// Add Kafka broker to management state
894    pub fn with_kafka_broker(
895        mut self,
896        kafka_broker: Arc<mockforge_kafka::KafkaMockBroker>,
897    ) -> Self {
898        self.kafka_broker = Some(kafka_broker);
899        self
900    }
901
902    #[cfg(feature = "amqp")]
903    /// Add AMQP broker to management state
904    pub fn with_amqp_broker(mut self, amqp_broker: Arc<mockforge_amqp::AmqpBroker>) -> Self {
905        self.amqp_broker = Some(amqp_broker);
906        self
907    }
908
909    #[cfg(feature = "chaos")]
910    /// Add chaos API state to management state
911    pub fn with_chaos_api_state(
912        mut self,
913        chaos_api_state: Arc<mockforge_chaos::api::ChaosApiState>,
914    ) -> Self {
915        self.chaos_api_state = Some(chaos_api_state);
916        self
917    }
918
919    /// Add server configuration to management state
920    pub fn with_server_config(
921        mut self,
922        server_config: Arc<RwLock<mockforge_core::config::ServerConfig>>,
923    ) -> Self {
924        self.server_config = Some(server_config);
925        self
926    }
927}
928
929/// Build the management API router
930pub fn management_router(state: ManagementState) -> Router {
931    let router = Router::new()
932        .route("/capabilities", get(get_capabilities))
933        .route("/health", get(health_check))
934        .route("/stats", get(get_stats))
935        .route("/config", get(get_config))
936        .route("/config/validate", post(validate_config))
937        .route("/config/bulk", post(bulk_update_config))
938        .route("/mocks", get(mocks::list_mocks))
939        .route("/mocks", post(mocks::create_mock))
940        .route("/mocks/{id}", get(mocks::get_mock))
941        .route("/mocks/{id}", put(mocks::update_mock))
942        .route("/mocks/{id}", delete(mocks::delete_mock))
943        .route("/export", get(export_mocks))
944        .route("/import", post(import_mocks))
945        .route("/spec", get(get_openapi_spec))
946        // Issue #79 round 12 — server-side spec violation feed for the
947        // new TUI "Conformance" screen. Backed by the bounded ring
948        // buffer in `mockforge_foundation::conformance_violations` that
949        // the OpenAPI router populates whenever
950        // `validate_request_with_all` rejects an incoming request.
951        .route("/conformance/violations", get(get_conformance_violations))
952        .route("/conformance/violations", delete(clear_conformance_violations));
953
954    #[cfg(feature = "smtp")]
955    let router = router
956        .route("/smtp/mailbox", get(protocols::list_smtp_emails))
957        .route("/smtp/mailbox", delete(protocols::clear_smtp_mailbox))
958        .route("/smtp/mailbox/{id}", get(protocols::get_smtp_email))
959        .route("/smtp/mailbox/export", get(protocols::export_smtp_mailbox))
960        .route("/smtp/mailbox/search", get(protocols::search_smtp_emails));
961
962    #[cfg(not(feature = "smtp"))]
963    let router = router;
964
965    // MQTT routes
966    #[cfg(feature = "mqtt")]
967    let router = router
968        .route("/mqtt/stats", get(protocols::get_mqtt_stats))
969        .route("/mqtt/clients", get(protocols::get_mqtt_clients))
970        .route("/mqtt/topics", get(protocols::get_mqtt_topics))
971        .route("/mqtt/clients/{client_id}", delete(protocols::disconnect_mqtt_client))
972        .route("/mqtt/messages/stream", get(protocols::mqtt_messages_stream))
973        .route("/mqtt/publish", post(protocols::publish_mqtt_message_handler))
974        .route("/mqtt/publish/batch", post(protocols::publish_mqtt_batch_handler));
975
976    #[cfg(not(feature = "mqtt"))]
977    let router = router
978        .route("/mqtt/publish", post(protocols::publish_mqtt_message_handler))
979        .route("/mqtt/publish/batch", post(protocols::publish_mqtt_batch_handler));
980
981    #[cfg(feature = "kafka")]
982    let router = router
983        .route("/kafka/stats", get(protocols::get_kafka_stats))
984        .route("/kafka/topics", get(protocols::get_kafka_topics))
985        .route("/kafka/topics/{topic}", get(protocols::get_kafka_topic))
986        .route("/kafka/groups", get(protocols::get_kafka_groups))
987        .route("/kafka/groups/{group_id}", get(protocols::get_kafka_group))
988        .route("/kafka/produce", post(protocols::produce_kafka_message))
989        .route("/kafka/produce/batch", post(protocols::produce_kafka_batch))
990        .route("/kafka/messages/stream", get(protocols::kafka_messages_stream));
991
992    #[cfg(not(feature = "kafka"))]
993    let router = router;
994
995    // AMQP routes
996    #[cfg(feature = "amqp")]
997    let router = router
998        .route("/amqp/stats", get(protocols::get_amqp_stats))
999        .route("/amqp/exchanges", get(protocols::get_amqp_exchanges))
1000        .route("/amqp/exchanges", post(protocols::declare_amqp_exchange))
1001        .route("/amqp/exchanges/{name}", delete(protocols::delete_amqp_exchange))
1002        .route("/amqp/exchanges/{name}/bindings", post(protocols::add_amqp_binding))
1003        .route("/amqp/queues", get(protocols::get_amqp_queues))
1004        .route("/amqp/queues", post(protocols::declare_amqp_queue))
1005        .route("/amqp/publish", post(protocols::publish_amqp_message));
1006
1007    #[cfg(not(feature = "amqp"))]
1008    let router = router;
1009
1010    // Migration pipeline routes
1011    let router = router
1012        .route("/migration/routes", get(migration::get_migration_routes))
1013        .route("/migration/routes/{pattern}/toggle", post(migration::toggle_route_migration))
1014        .route("/migration/routes/{pattern}", put(migration::set_route_migration_mode))
1015        .route("/migration/groups/{group}/toggle", post(migration::toggle_group_migration))
1016        .route("/migration/groups/{group}", put(migration::set_group_migration_mode))
1017        .route("/migration/groups", get(migration::get_migration_groups))
1018        .route("/migration/status", get(migration::get_migration_status));
1019
1020    // Proxy replacement rules routes
1021    let router = router
1022        .route("/proxy/rules", get(proxy::list_proxy_rules))
1023        .route("/proxy/rules", post(proxy::create_proxy_rule))
1024        .route("/proxy/rules/{id}", get(proxy::get_proxy_rule))
1025        .route("/proxy/rules/{id}", put(proxy::update_proxy_rule))
1026        .route("/proxy/rules/{id}", delete(proxy::delete_proxy_rule))
1027        .route("/proxy/inspect", get(proxy::get_proxy_inspect));
1028
1029    // AI-powered features
1030    let router = router.route("/ai/generate-spec", post(generate_ai_spec));
1031
1032    // Snapshot diff endpoints
1033    let router = router.nest(
1034        "/snapshot-diff",
1035        crate::handlers::snapshot_diff::snapshot_diff_router(state.clone()),
1036    );
1037
1038    #[cfg(feature = "behavioral-cloning")]
1039    let router = router.route("/mockai/generate-openapi", post(generate_openapi_from_traffic));
1040
1041    let router = router
1042        .route("/mockai/learn", post(learn_from_examples))
1043        .route("/mockai/rules/explanations", get(list_rule_explanations))
1044        .route("/mockai/rules/{id}/explanation", get(get_rule_explanation))
1045        .route("/chaos/config", get(get_chaos_config))
1046        .route("/chaos/config", post(update_chaos_config))
1047        .route("/network/profiles", get(list_network_profiles))
1048        .route("/network/profile/apply", post(apply_network_profile));
1049
1050    // State machine API routes
1051    let router =
1052        router.nest("/state-machines", crate::state_machine_api::create_state_machine_routes());
1053
1054    // Conformance testing API routes
1055    #[cfg(feature = "conformance")]
1056    let router = router.nest_service(
1057        "/conformance",
1058        crate::handlers::conformance::conformance_router(state.conformance_state.clone()),
1059    );
1060    #[cfg(not(feature = "conformance"))]
1061    let router = router;
1062
1063    router.with_state(state)
1064}
1065
1066/// Build the management API router with UI Builder support
1067pub fn management_router_with_ui_builder(
1068    state: ManagementState,
1069    server_config: mockforge_core::config::ServerConfig,
1070) -> Router {
1071    use crate::ui_builder::{create_ui_builder_router, UIBuilderState};
1072
1073    // Create the base management router
1074    let management = management_router(state);
1075
1076    // Create UI Builder state and router
1077    let ui_builder_state = UIBuilderState::new(server_config);
1078    let ui_builder = create_ui_builder_router(ui_builder_state);
1079
1080    // Nest UI Builder under /ui-builder
1081    management.nest("/ui-builder", ui_builder)
1082}
1083
1084/// Build management router with spec import API
1085pub fn management_router_with_spec_import(state: ManagementState) -> Router {
1086    use crate::spec_import::{spec_import_router, SpecImportState};
1087
1088    // Create base management router
1089    let management = management_router(state);
1090
1091    // Merge with spec import router
1092    Router::new()
1093        .merge(management)
1094        .merge(spec_import_router(SpecImportState::new()))
1095}
1096
1097/// Match an incoming request against mocks registered via the
1098/// `POST /__mockforge/api/mocks` endpoint and return the first match's
1099/// response (ordered by descending priority). Returns `None` if nothing
1100/// matches, so the caller can chain it with its existing 404.
1101///
1102/// This is what lets the `@mockforge-dev/sdk` Node.js SDK register a stub
1103/// dynamically and have subsequent HTTP requests actually hit it — without
1104/// it, stubs live in `ManagementState` but no route dispatches to them, so
1105/// every SDK test would 404.
1106pub async fn serve_dynamic_mock(state: &ManagementState, req: Request<Body>) -> Option<Response> {
1107    let method = req.method().as_str().to_string();
1108    let path = req.uri().path().to_string();
1109
1110    let query_params: std::collections::HashMap<String, String> = req
1111        .uri()
1112        .query()
1113        .map(|q| url::form_urlencoded::parse(q.as_bytes()).into_owned().collect())
1114        .unwrap_or_default();
1115
1116    let headers: std::collections::HashMap<String, String> = req
1117        .headers()
1118        .iter()
1119        .filter_map(|(k, v)| v.to_str().ok().map(|v| (k.as_str().to_string(), v.to_string())))
1120        .collect();
1121
1122    let (_parts, body) = req.into_parts();
1123    let body_bytes = axum::body::to_bytes(body, 1024 * 1024).await.ok()?;
1124    let body_opt: Option<&[u8]> = if body_bytes.is_empty() {
1125        None
1126    } else {
1127        Some(&body_bytes)
1128    };
1129
1130    let mocks = state.mocks.read().await;
1131
1132    let mut candidates: Vec<&MockConfig> = mocks
1133        .iter()
1134        .filter(|m| mock_matches_request(m, &method, &path, &headers, &query_params, body_opt))
1135        .collect();
1136    if candidates.is_empty() {
1137        return None;
1138    }
1139    candidates.sort_by_key(|m| -(m.priority.unwrap_or(0)));
1140    let mock = candidates.first()?;
1141
1142    if let Some(ms) = mock.latency_ms {
1143        if ms > 0 {
1144            tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
1145        }
1146    }
1147
1148    let status = mock
1149        .status_code
1150        .and_then(|c| StatusCode::from_u16(c).ok())
1151        .unwrap_or(StatusCode::OK);
1152
1153    // Honor MOCKFORGE_RESPONSE_TEMPLATE_EXPAND for mocks created via the
1154    // management API. The full templater in mockforge-core handles
1155    // `{{faker.email}}`, `{{uuid}}`, `{{randInt …}}`, etc. — it's gated
1156    // on the env var because it's the same opt-in as everywhere else
1157    // (config-loaded routes, OpenAPI overrides). Run inside spawn_blocking
1158    // because the templater's rng() and faker providers are not Send-safe
1159    // across `.await` points.
1160    let template_expand = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
1161        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1162        .unwrap_or(false);
1163    let body_value = if template_expand {
1164        let body_clone = mock.response.body.clone();
1165        match tokio::task::spawn_blocking(move || {
1166            mockforge_core::templating::expand_tokens(&body_clone)
1167        })
1168        .await
1169        {
1170            Ok(expanded) => expanded,
1171            Err(_) => mock.response.body.clone(),
1172        }
1173    } else {
1174        mock.response.body.clone()
1175    };
1176
1177    let body_bytes_out = serde_json::to_vec(&body_value).unwrap_or_default();
1178    let mut response = Response::builder().status(status);
1179
1180    let mut has_content_type = false;
1181    if let Some(h) = &mock.response.headers {
1182        for (k, v) in h {
1183            if k.eq_ignore_ascii_case("content-type") {
1184                has_content_type = true;
1185            }
1186            if let (Ok(name), Ok(value)) =
1187                (HeaderName::from_bytes(k.as_bytes()), HeaderValue::from_str(v))
1188            {
1189                response = response.header(name, value);
1190            }
1191        }
1192    }
1193    if !has_content_type {
1194        response = response.header("content-type", "application/json");
1195    }
1196
1197    Some(
1198        response
1199            .body(Body::from(body_bytes_out))
1200            .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()),
1201    )
1202}
1203
1204/// Axum fallback handler for the main router: tries to serve a dynamic mock,
1205/// or returns 404. Only invoked when nothing else in the router matched.
1206///
1207/// Wire via `.fallback_service(axum::routing::any(dynamic_mock_fallback).with_state(state))`
1208/// so the handler's own `State<ManagementState>` extractor is satisfied
1209/// without forcing the parent router to adopt `ManagementState` as its state.
1210pub async fn dynamic_mock_fallback(
1211    State(state): State<ManagementState>,
1212    req: Request<Body>,
1213) -> Response {
1214    // Issue #79 round 13 — capture the request method/path/query
1215    // before the body is consumed so we can record unmatched 404s
1216    // (Srikanth's (a) ask: surface paths the spec doesn't know about).
1217    // Only records when no dynamic mock matches — if a dynamic mock
1218    // serves the request, the path WAS handled, just not by OpenAPI.
1219    let method = req.method().as_str().to_string();
1220    let path = req.uri().path().to_string();
1221    let query = req.uri().query().unwrap_or_default().to_string();
1222
1223    match serve_dynamic_mock(&state, req).await {
1224        Some(resp) => resp,
1225        None => {
1226            // Issue #79 round 14 — shadow mode returns 200 for unknown
1227            // paths (instead of 404) while still recording them, so a
1228            // proxy replay flows through non-blocking.
1229            //
1230            // Issue #79 round 20 — but only when the path is INSIDE
1231            // the configured `--base-path` prefix. Pre-round-20, shadow
1232            // returned 200 for every unmatched path, including paths
1233            // outside the configured surface (e.g. `/api123/...` when
1234            // the server is started with `--base-path /api`). Srikanth
1235            // hit this when his client config drifted from the server's
1236            // base path: every request looked "shadow" instead of "404
1237            // because that prefix isn't ours".
1238            let shadow_enabled = mockforge_foundation::unknown_paths::shadow_mode_enabled();
1239            let in_base_path = match state.base_path.as_deref() {
1240                Some(bp) => path_in_base(&path, bp),
1241                None => true, // no base path configured — shadow applies to everything
1242            };
1243            let shadow = shadow_enabled && in_base_path;
1244            let status = if shadow {
1245                StatusCode::OK
1246            } else {
1247                StatusCode::NOT_FOUND
1248            };
1249            mockforge_foundation::unknown_paths::record(
1250                mockforge_foundation::unknown_paths::UnknownPathRequest {
1251                    timestamp: chrono::Utc::now(),
1252                    method,
1253                    path,
1254                    client_ip: "unknown".to_string(),
1255                    query,
1256                    status: status.as_u16(),
1257                },
1258            );
1259            if shadow {
1260                // Minimal JSON stub so clients expecting a body don't
1261                // choke. Shadow mode is for traffic-replay observability,
1262                // not realistic response shapes.
1263                (
1264                    StatusCode::OK,
1265                    [(http::header::CONTENT_TYPE, "application/json")],
1266                    r#"{"shadow":true,"matched":false}"#,
1267                )
1268                    .into_response()
1269            } else {
1270                StatusCode::NOT_FOUND.into_response()
1271            }
1272        }
1273    }
1274}
1275
1276/// Round 20 — is `path` under `base_path` for shadow-mode purposes?
1277///
1278/// Matches at the **segment** boundary, so `/api123/foo` does not match
1279/// `--base-path /api`. The check is `path == bp || path.starts_with(bp+'/')`,
1280/// which also accepts the exact base path itself (a request to `/api`
1281/// when configured for `/api` is in-prefix).
1282pub(crate) fn path_in_base(path: &str, base_path: &str) -> bool {
1283    let bp = base_path.trim_end_matches('/');
1284    if bp.is_empty() {
1285        return true;
1286    }
1287    if path == bp {
1288        return true;
1289    }
1290    // Must end at a segment boundary: bp followed by `/` or end-of-string.
1291    path.starts_with(bp) && path.as_bytes().get(bp.len()).copied() == Some(b'/')
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296    use super::*;
1297
1298    /// Round 20 — `path_in_base` accepts the base path itself plus
1299    /// anything under a `/`-delimited segment boundary, and rejects
1300    /// paths that merely share a string prefix (e.g. `/api123` is not
1301    /// under `/api`).
1302    #[test]
1303    fn path_in_base_segment_boundary() {
1304        // Exact match.
1305        assert!(path_in_base("/api", "/api"));
1306        // Under prefix.
1307        assert!(path_in_base("/api/users", "/api"));
1308        assert!(path_in_base("/api/v1/items/42", "/api"));
1309        // The Srikanth bug: same string prefix but different segment.
1310        assert!(!path_in_base("/api123", "/api"));
1311        assert!(!path_in_base("/api123/foo", "/api"));
1312        // Sibling prefix is not in-base.
1313        assert!(!path_in_base("/", "/api"));
1314        assert!(!path_in_base("/admin", "/api"));
1315        // Trailing slash on base normalises.
1316        assert!(path_in_base("/api/users", "/api/"));
1317        // Empty base = no gate.
1318        assert!(path_in_base("/anything", ""));
1319    }
1320
1321    /// Round 20 — `ManagementState::with_base_path` normalises
1322    /// trailing slash and treats empty / `/` as no-prefix.
1323    #[test]
1324    fn with_base_path_normalises() {
1325        let s = ManagementState::new(None, None, 3000).with_base_path(Some("/api".to_string()));
1326        assert_eq!(s.base_path.as_deref(), Some("/api"));
1327        let s = ManagementState::new(None, None, 3000).with_base_path(Some("/api/".to_string()));
1328        assert_eq!(s.base_path.as_deref(), Some("/api"));
1329        let s = ManagementState::new(None, None, 3000).with_base_path(Some("".to_string()));
1330        assert_eq!(s.base_path.as_deref(), None);
1331        let s = ManagementState::new(None, None, 3000).with_base_path(Some("/".to_string()));
1332        assert_eq!(s.base_path.as_deref(), None);
1333        let s = ManagementState::new(None, None, 3000).with_base_path(None);
1334        assert_eq!(s.base_path.as_deref(), None);
1335    }
1336
1337    #[tokio::test]
1338    async fn test_create_and_get_mock() {
1339        let state = ManagementState::new(None, None, 3000);
1340
1341        let mock = MockConfig {
1342            id: "test-1".to_string(),
1343            name: "Test Mock".to_string(),
1344            method: "GET".to_string(),
1345            path: "/test".to_string(),
1346            response: MockResponse {
1347                body: serde_json::json!({"message": "test"}),
1348                headers: None,
1349            },
1350            enabled: true,
1351            latency_ms: None,
1352            status_code: Some(200),
1353            request_match: None,
1354            priority: None,
1355            scenario: None,
1356            required_scenario_state: None,
1357            new_scenario_state: None,
1358        };
1359
1360        // Create mock
1361        {
1362            let mut mocks = state.mocks.write().await;
1363            mocks.push(mock.clone());
1364        }
1365
1366        // Get mock
1367        let mocks = state.mocks.read().await;
1368        let found = mocks.iter().find(|m| m.id == "test-1");
1369        assert!(found.is_some());
1370        assert_eq!(found.unwrap().name, "Test Mock");
1371    }
1372
1373    #[tokio::test]
1374    async fn test_server_stats() {
1375        let state = ManagementState::new(None, None, 3000);
1376
1377        // Add some mocks
1378        {
1379            let mut mocks = state.mocks.write().await;
1380            mocks.push(MockConfig {
1381                id: "1".to_string(),
1382                name: "Mock 1".to_string(),
1383                method: "GET".to_string(),
1384                path: "/test1".to_string(),
1385                response: MockResponse {
1386                    body: serde_json::json!({}),
1387                    headers: None,
1388                },
1389                enabled: true,
1390                latency_ms: None,
1391                status_code: Some(200),
1392                request_match: None,
1393                priority: None,
1394                scenario: None,
1395                required_scenario_state: None,
1396                new_scenario_state: None,
1397            });
1398            mocks.push(MockConfig {
1399                id: "2".to_string(),
1400                name: "Mock 2".to_string(),
1401                method: "POST".to_string(),
1402                path: "/test2".to_string(),
1403                response: MockResponse {
1404                    body: serde_json::json!({}),
1405                    headers: None,
1406                },
1407                enabled: false,
1408                latency_ms: None,
1409                status_code: Some(201),
1410                request_match: None,
1411                priority: None,
1412                scenario: None,
1413                required_scenario_state: None,
1414                new_scenario_state: None,
1415            });
1416        }
1417
1418        let mocks = state.mocks.read().await;
1419        assert_eq!(mocks.len(), 2);
1420        assert_eq!(mocks.iter().filter(|m| m.enabled).count(), 1);
1421    }
1422
1423    #[test]
1424    fn test_mock_matches_request_with_xpath_absolute_path() {
1425        let mock = MockConfig {
1426            id: "xpath-1".to_string(),
1427            name: "XPath Match".to_string(),
1428            method: "POST".to_string(),
1429            path: "/xml".to_string(),
1430            response: MockResponse {
1431                body: serde_json::json!({"ok": true}),
1432                headers: None,
1433            },
1434            enabled: true,
1435            latency_ms: None,
1436            status_code: Some(200),
1437            request_match: Some(RequestMatchCriteria {
1438                xpath: Some("/root/order/id".to_string()),
1439                ..Default::default()
1440            }),
1441            priority: None,
1442            scenario: None,
1443            required_scenario_state: None,
1444            new_scenario_state: None,
1445        };
1446
1447        let body = br#"<root><order><id>123</id></order></root>"#;
1448        let headers = std::collections::HashMap::new();
1449        let query = std::collections::HashMap::new();
1450
1451        assert!(mock_matches_request(&mock, "POST", "/xml", &headers, &query, Some(body)));
1452    }
1453
1454    #[test]
1455    fn test_mock_matches_request_with_xpath_text_predicate() {
1456        let mock = MockConfig {
1457            id: "xpath-2".to_string(),
1458            name: "XPath Predicate Match".to_string(),
1459            method: "POST".to_string(),
1460            path: "/xml".to_string(),
1461            response: MockResponse {
1462                body: serde_json::json!({"ok": true}),
1463                headers: None,
1464            },
1465            enabled: true,
1466            latency_ms: None,
1467            status_code: Some(200),
1468            request_match: Some(RequestMatchCriteria {
1469                xpath: Some("//order/id[text()='123']".to_string()),
1470                ..Default::default()
1471            }),
1472            priority: None,
1473            scenario: None,
1474            required_scenario_state: None,
1475            new_scenario_state: None,
1476        };
1477
1478        let body = br#"<root><order><id>123</id></order></root>"#;
1479        let headers = std::collections::HashMap::new();
1480        let query = std::collections::HashMap::new();
1481
1482        assert!(mock_matches_request(&mock, "POST", "/xml", &headers, &query, Some(body)));
1483    }
1484
1485    #[test]
1486    fn test_mock_matches_request_with_xpath_no_match() {
1487        let mock = MockConfig {
1488            id: "xpath-3".to_string(),
1489            name: "XPath No Match".to_string(),
1490            method: "POST".to_string(),
1491            path: "/xml".to_string(),
1492            response: MockResponse {
1493                body: serde_json::json!({"ok": true}),
1494                headers: None,
1495            },
1496            enabled: true,
1497            latency_ms: None,
1498            status_code: Some(200),
1499            request_match: Some(RequestMatchCriteria {
1500                xpath: Some("//order/id[text()='456']".to_string()),
1501                ..Default::default()
1502            }),
1503            priority: None,
1504            scenario: None,
1505            required_scenario_state: None,
1506            new_scenario_state: None,
1507        };
1508
1509        let body = br#"<root><order><id>123</id></order></root>"#;
1510        let headers = std::collections::HashMap::new();
1511        let query = std::collections::HashMap::new();
1512
1513        assert!(!mock_matches_request(&mock, "POST", "/xml", &headers, &query, Some(body)));
1514    }
1515}