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