Skip to main content

camel_component_http/
lib.rs

1pub mod bundle;
2pub mod config;
3mod header_policy;
4pub mod health;
5pub mod registry;
6pub(crate) mod rest_match;
7pub(crate) mod ssrf;
8pub mod static_config;
9pub mod static_dispatch;
10pub mod static_endpoint;
11pub(crate) mod tls_reload;
12use crate::config::parse_ok_status_code_range;
13pub use bundle::HttpBundle;
14pub use bundle::HttpStaticBundle;
15pub use config::HttpConfig;
16pub use health::HttpHealthCheck;
17pub use registry::HttpRouteRegistry;
18pub use static_config::HttpStaticConfig;
19pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
20
21use std::collections::HashMap;
22use std::future::Future;
23use std::pin::Pin;
24
25use std::sync::{Arc, Mutex, OnceLock};
26use std::task::{Context, Poll};
27use std::time::Duration;
28
29use tokio::sync::OnceCell;
30use tower::Layer;
31use tower::Service;
32use tracing::debug;
33
34use axum::body::BodyDataStream;
35use camel_api::component_metadata::ComponentMetadata;
36use camel_auth::bearer_token_layer::BearerTokenLayer;
37use camel_auth::oauth2::TokenProvider;
38use camel_component_api::tls_source::ServerTlsSource;
39use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
40use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
41use camel_component_api::{UriComponents, UriConfig, parse_uri};
42use futures::TryStreamExt;
43use futures::stream::BoxStream;
44
45// ---------------------------------------------------------------------------
46// HttpEndpointConfig
47// ---------------------------------------------------------------------------
48
49/// Configuration for an HTTP client (producer) endpoint.
50///
51/// # Memory Limits
52///
53/// HTTP operations enforce conservative memory limits to prevent denial-of-service
54/// attacks from untrusted network sources. These limits are significantly lower than
55/// file component limits (100MB) because HTTP typically handles API responses rather
56/// than large file transfers, and clients may be untrusted.
57///
58/// ## Default Limits
59///
60/// - **HTTP client body**: 10MB (typical API responses)
61/// - **HTTP server request**: 2MB (untrusted network input - see `HttpServerConfig`)
62/// - **HTTP server response**: 10MB (same as client - see `HttpServerConfig`)
63///
64/// ## Rationale
65///
66/// The 10MB limit for HTTP client responses is appropriate for most API interactions
67/// while providing protection against:
68/// - Malicious servers sending oversized responses
69/// - Runaway processes generating unexpectedly large payloads
70/// - Memory exhaustion attacks
71///
72/// The 2MB server request limit is even more conservative because it handles input
73/// from potentially untrusted clients on the public internet.
74///
75/// ## Overriding Limits
76///
77/// Override the default client body limit using the `maxBodySize` URI parameter:
78///
79/// ```text
80/// http://api.example.com/large-data?maxBodySize=52428800
81/// ```
82///
83/// For server endpoints, use `maxRequestBody` and `maxResponseBody` parameters:
84///
85/// ```text
86/// http://0.0.0.0:8080/upload?maxRequestBody=52428800
87/// ```
88///
89/// ## Behavior When Exceeded
90///
91/// When a body exceeds the configured limit:
92/// - An error is returned immediately
93/// - No memory is exhausted - the limit is checked before allocation
94/// - The HTTP connection is terminated cleanly
95///
96/// ## Security Considerations
97///
98/// HTTP endpoints should be treated with more caution than file endpoints because:
99/// - Clients may be unknown and untrusted
100/// - Network traffic can be spoofed or malicious
101/// - DoS attacks often exploit unbounded resource consumption
102///
103/// Only increase limits when you control both ends of the connection or when
104/// business requirements demand larger payloads.
105#[derive(Debug, Clone)]
106pub struct HttpEndpointConfig {
107    pub base_url: String,
108    pub http_method: Option<String>,
109    pub throw_exception_on_failure: bool,
110    pub ok_status_code_range: (u16, u16),
111    pub response_timeout: Option<Duration>,
112    pub query_params: HashMap<String, String>,
113    pub allow_internal: bool,
114    pub blocked_hosts: Vec<String>,
115    pub max_body_size: usize,
116    pub read_timeout_ms: u64,
117    pub max_response_bytes: usize,
118    pub auth: HttpAuth,
119    pub token_provider: Option<Arc<dyn TokenProvider>>,
120    pub user_agent: Option<String>,
121    pub bridge_endpoint: bool,
122    pub connection_close: bool,
123    pub skip_request_headers: Vec<String>,
124    pub skip_response_headers: Vec<String>,
125    pub follow_redirects: bool,
126    pub max_redirects: usize,
127}
128
129#[derive(Clone, PartialEq)]
130pub enum HttpAuth {
131    None,
132    Basic { username: String, password: String },
133    Bearer { token: String },
134}
135
136impl std::fmt::Debug for HttpAuth {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        match self {
139            HttpAuth::None => f.write_str("None"),
140            HttpAuth::Basic { username, .. } => f
141                .debug_struct("Basic")
142                .field("username", username)
143                .field("password", &"***")
144                .finish(),
145            HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
146        }
147    }
148}
149
150/// Camel options that should NOT be forwarded as HTTP query params
151const HTTP_CAMEL_OPTIONS: &[&str] = &[
152    "httpMethod",
153    "throwExceptionOnFailure",
154    "okStatusCodeRange",
155    "followRedirects",
156    "maxRedirects",
157    "connectTimeout",
158    "responseTimeout",
159    "allowInternal",
160    "blockedHosts",
161    "maxBodySize",
162    "readTimeout",
163    "maxResponseBytes",
164    "authMethod",
165    "authUsername",
166    "authPassword",
167    "authBearerToken",
168    "userAgent",
169    "cookieHandling",
170    "bridgeEndpoint",
171    "connectionClose",
172    "skipRequestHeaders",
173    "skipResponseHeaders",
174];
175
176impl UriConfig for HttpEndpointConfig {
177    /// Returns "http" as the primary scheme (also accepts "https")
178    fn scheme() -> &'static str {
179        "http"
180    }
181
182    fn from_uri(uri: &str) -> Result<Self, CamelError> {
183        let parts = parse_uri(uri)?;
184        Self::from_components(parts)
185    }
186
187    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
188        // Validate scheme - accept both http and https
189        if parts.scheme != "http" && parts.scheme != "https" {
190            return Err(CamelError::InvalidUri(format!(
191                "expected scheme 'http' or 'https', got '{}'",
192                parts.scheme
193            )));
194        }
195
196        // Construct base_url from scheme + path
197        // e.g., "http://localhost:8080/api" from scheme "http" and path "//localhost:8080/api"
198        let base_url = format!("{}:{}", parts.scheme, parts.path);
199
200        let http_method = parts.params.get("httpMethod").cloned();
201
202        let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
203            Some(v) => parse_bool_param_http(v).map_err(|e| {
204                CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
205            })?,
206            None => true,
207        };
208
209        // Parse status code range from "start-end" format (e.g., "200-299")
210        let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
211            Some(v) => parse_ok_status_code_range(v)?,
212            None => (200, 299),
213        };
214
215        let response_timeout = match parts.params.get("responseTimeout") {
216            Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
217                CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
218            })?),
219            None => None,
220        };
221
222        // SSRF protection settings
223        let allow_internal = match parts.params.get("allowInternal") {
224            Some(v) => parse_bool_param_http(v).map_err(|e| {
225                CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
226            })?,
227            None => false, // Default: block private IPs
228        };
229
230        // Parse comma-separated blocked hosts
231        let blocked_hosts = parts
232            .params
233            .get("blockedHosts")
234            .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
235            .unwrap_or_default();
236
237        let max_body_size = match parts.params.get("maxBodySize") {
238            Some(v) => v.parse::<usize>().map_err(|e| {
239                CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
240            })?,
241            None => 10 * 1024 * 1024, // Default: 10MB
242        };
243
244        let read_timeout_ms = match parts.params.get("readTimeout") {
245            Some(v) => v.parse::<u64>().map_err(|e| {
246                CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
247            })?,
248            None => 30_000, // Default: 30s
249        };
250
251        let max_response_bytes = match parts.params.get("maxResponseBytes") {
252            Some(v) => v.parse::<usize>().map_err(|e| {
253                CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
254            })?,
255            None => 10 * 1024 * 1024, // Default: 10MB
256        };
257
258        let auth = parse_auth_from_params(&parts.params)?;
259
260        let user_agent = parts.params.get("userAgent").cloned();
261
262        if parts.params.contains_key("cookieHandling") {
263            return Err(CamelError::InvalidUri(
264                "cookieHandling is not supported".into(),
265            ));
266        }
267
268        let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
269            Some(v) => parse_bool_param_http(v).map_err(|e| {
270                CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
271            })?,
272            None => false,
273        };
274
275        let connection_close = match parts.params.get("connectionClose") {
276            Some(v) => parse_bool_param_http(v).map_err(|e| {
277                CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
278            })?,
279            None => false,
280        };
281
282        let skip_request_headers = parts
283            .params
284            .get("skipRequestHeaders")
285            .map(|v| {
286                v.split(',')
287                    .map(str::trim)
288                    .filter(|s| !s.is_empty())
289                    .map(|s| s.to_ascii_lowercase())
290                    .collect::<Vec<_>>()
291            })
292            .unwrap_or_default();
293
294        let skip_response_headers = parts
295            .params
296            .get("skipResponseHeaders")
297            .map(|v| {
298                v.split(',')
299                    .map(str::trim)
300                    .filter(|s| !s.is_empty())
301                    .map(|s| s.to_ascii_lowercase())
302                    .collect::<Vec<_>>()
303            })
304            .unwrap_or_default();
305
306        let follow_redirects = match parts.params.get("followRedirects") {
307            Some(v) => parse_bool_param_http(v).map_err(|e| {
308                CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
309            })?,
310            None => false,
311        };
312
313        let max_redirects = match parts.params.get("maxRedirects") {
314            Some(v) => v.parse::<usize>().map_err(|e| {
315                CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
316            })?,
317            None => 10,
318        };
319
320        // Collect remaining params (not Camel options) as query params
321        let query_params: HashMap<String, String> = parts
322            .params
323            .into_iter()
324            .filter(|(k, _)| !HTTP_CAMEL_OPTIONS.contains(&k.as_str()))
325            .collect();
326
327        Ok(Self {
328            base_url,
329            http_method,
330            throw_exception_on_failure,
331            ok_status_code_range,
332            response_timeout,
333            query_params,
334            allow_internal,
335            blocked_hosts,
336            max_body_size,
337            read_timeout_ms,
338            max_response_bytes,
339            auth,
340            token_provider: None,
341            user_agent,
342            bridge_endpoint,
343            connection_close,
344            skip_request_headers,
345            skip_response_headers,
346            follow_redirects,
347            max_redirects,
348        })
349    }
350}
351
352/// Private container for macro-derived `uri_options()` and `metadata()`.
353///
354/// Mirrors the URI query parameters parsed by `HttpEndpointConfig::from_components`.
355/// `HttpEndpointConfig` holds typed fields (tuples, `Duration`, `HttpAuth`,
356/// `Arc<dyn TokenProvider>`) that the derive cannot represent, so metadata
357/// derivation targets this inner type whose fields are all URI-param-compatible.
358#[derive(Debug, Clone, UriConfig)]
359#[allow(dead_code)]
360#[uri_scheme = "http"]
361#[uri_config(
362    skip_impl,
363    metadata(
364        scheme = "http",
365        description = "HTTP client and server component",
366        producer,
367        consumer,
368        streaming
369    ),
370    crate = "camel_component_api"
371)]
372struct HttpEndpointUriConfig {
373    #[allow(dead_code)]
374    _base_url: String,
375
376    #[uri_param(
377        name = "httpMethod",
378        desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
379    )]
380    http_method: Option<String>,
381
382    #[uri_param(
383        name = "throwExceptionOnFailure",
384        default = "true",
385        desc = "Throw on non-2xx status"
386    )]
387    throw_exception_on_failure: bool,
388
389    #[uri_param(
390        name = "okStatusCodeRange",
391        default = "200-299",
392        desc = "Success status code range"
393    )]
394    ok_status_code_range: String,
395
396    #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
397    response_timeout: Option<u64>,
398
399    #[uri_param(
400        name = "allowInternal",
401        default = "false",
402        desc = "Allow private/internal network destinations (SSRF)"
403    )]
404    allow_internal: bool,
405
406    #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
407    blocked_hosts: Option<String>,
408
409    #[uri_param(
410        name = "maxBodySize",
411        default = "10485760",
412        desc = "Max request/response body bytes"
413    )]
414    max_body_size: u64,
415
416    #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
417    read_timeout: Option<u64>,
418
419    #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
420    max_response_bytes: Option<u64>,
421
422    #[uri_param(
423        name = "authMethod",
424        kind = "enum:Basic,Bearer",
425        desc = "Authentication method"
426    )]
427    auth_method: Option<String>,
428
429    #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
430    auth_username: Option<String>,
431
432    #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
433    auth_password: Option<String>,
434
435    #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
436    auth_bearer_token: Option<String>,
437
438    #[uri_param(name = "userAgent", desc = "User-Agent header")]
439    user_agent: Option<String>,
440
441    #[uri_param(
442        name = "bridgeEndpoint",
443        default = "false",
444        desc = "Bridge endpoint mode"
445    )]
446    bridge_endpoint: bool,
447
448    #[uri_param(
449        name = "connectionClose",
450        default = "false",
451        desc = "Send Connection: close"
452    )]
453    connection_close: bool,
454
455    #[uri_param(
456        name = "skipRequestHeaders",
457        desc = "Comma-separated request headers to skip"
458    )]
459    skip_request_headers: Option<String>,
460
461    #[uri_param(
462        name = "skipResponseHeaders",
463        desc = "Comma-separated response headers to skip"
464    )]
465    skip_response_headers: Option<String>,
466
467    #[uri_param(
468        name = "followRedirects",
469        default = "false",
470        desc = "Follow HTTP redirects"
471    )]
472    follow_redirects: bool,
473
474    #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
475    max_redirects: u64,
476}
477
478impl HttpEndpointConfig {
479    /// Component metadata for the http/https scheme, derived from the
480    /// `#[uri_param]` fields on `HttpEndpointUriConfig`.
481    pub fn metadata() -> ComponentMetadata {
482        HttpEndpointUriConfig::metadata()
483    }
484
485    /// URI option definitions, derived from `#[uri_param]` fields.
486    pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
487        HttpEndpointUriConfig::uri_options()
488    }
489}
490
491fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
492    let Some(method) = params.get("authMethod") else {
493        return Ok(HttpAuth::None);
494    };
495
496    if method.eq_ignore_ascii_case("none") {
497        return Ok(HttpAuth::None);
498    }
499
500    if method.eq_ignore_ascii_case("basic") {
501        let username = params.get("authUsername").cloned().ok_or_else(|| {
502            CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
503        })?;
504        let password = params.get("authPassword").cloned().ok_or_else(|| {
505            CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
506        })?;
507        return Ok(HttpAuth::Basic { username, password });
508    }
509
510    if method.eq_ignore_ascii_case("bearer") {
511        let token = params.get("authBearerToken").cloned().ok_or_else(|| {
512            CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
513        })?;
514        return Ok(HttpAuth::Bearer { token });
515    }
516
517    Err(CamelError::InvalidUri(format!(
518        "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
519    )))
520}
521
522fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
523    match value.to_ascii_lowercase().as_str() {
524        "true" | "1" | "yes" => Ok(true),
525        "false" | "0" | "no" => Ok(false),
526        _ => Err(CamelError::InvalidUri(format!(
527            "invalid boolean value: '{value}'"
528        ))),
529    }
530}
531
532impl HttpEndpointConfig {
533    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
534        let parts = parse_uri(uri)?;
535        let mut endpoint = Self::from_components(parts.clone())?;
536        if endpoint.response_timeout.is_none() {
537            endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
538        }
539        if !parts.params.contains_key("allowInternal") {
540            endpoint.allow_internal = config.allow_internal;
541        }
542        if !parts.params.contains_key("blockedHosts") {
543            endpoint.blocked_hosts = config.blocked_hosts.clone();
544        }
545        if !parts.params.contains_key("maxBodySize") {
546            endpoint.max_body_size = config.max_body_size;
547        }
548        if !parts.params.contains_key("readTimeout") {
549            endpoint.read_timeout_ms = config.read_timeout_ms;
550        }
551        if !parts.params.contains_key("maxResponseBytes") {
552            endpoint.max_response_bytes = config.max_response_bytes;
553        }
554        if !parts.params.contains_key("okStatusCodeRange")
555            && let Some(range) = &config.ok_status_code_range
556        {
557            endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
558        }
559        if !parts.params.contains_key("followRedirects") {
560            endpoint.follow_redirects = config.follow_redirects;
561        }
562        if !parts.params.contains_key("maxRedirects") {
563            endpoint.max_redirects = config.max_redirects.unwrap_or(10);
564        }
565
566        Ok(endpoint)
567    }
568}
569
570// ---------------------------------------------------------------------------
571// HttpServerConfig
572// ---------------------------------------------------------------------------
573
574/// Configuration for an HTTP server (consumer) endpoint.
575#[derive(Debug, Clone)]
576pub struct HttpServerConfig {
577    /// URI scheme ("http" or "https") parsed from the endpoint URI.
578    pub scheme: String,
579    /// Bind address, e.g. "0.0.0.0" or "127.0.0.1".
580    pub host: String,
581    /// TCP port to listen on.
582    pub port: u16,
583    /// URL path this consumer handles, e.g. "/orders".
584    pub path: String,
585    /// Maximum request body size in bytes.
586    pub max_request_body: usize,
587    /// Maximum response body size for materializing streams in bytes.
588    pub max_response_body: usize,
589    /// Maximum number of in-flight requests handled concurrently by this server.
590    pub max_inflight_requests: usize,
591    /// HTTP method this consumer handles (e.g. `"GET"`). When `Some`,
592    /// the consumer registers as a method-aware REST endpoint and the
593    /// path is treated as a template (e.g. `/users/{id}` is matched
594    /// against any `/users/<value>`). When `None`, the consumer
595    /// registers in the legacy path-only `api_routes` registry.
596    /// Extracted from the `httpMethod=` URI param at config build time.
597    pub method: Option<String>,
598    /// Server-side TLS config. Populated from `tlsCert`/`tlsKey` URI params.
599    /// `None` for plain HTTP servers.
600    pub tls_config: Option<crate::config::ServerTlsConfig>,
601}
602
603impl UriConfig for HttpServerConfig {
604    /// Returns "http" as the primary scheme (also accepts "https")
605    fn scheme() -> &'static str {
606        "http"
607    }
608
609    fn from_uri(uri: &str) -> Result<Self, CamelError> {
610        let parts = parse_uri(uri)?;
611        Self::from_components(parts)
612    }
613
614    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
615        // Validate scheme - accept both http and https
616        if parts.scheme != "http" && parts.scheme != "https" {
617            return Err(CamelError::InvalidUri(format!(
618                "expected scheme 'http' or 'https', got '{}'",
619                parts.scheme
620            )));
621        }
622
623        // parts.path is everything after the scheme colon, e.g. "//0.0.0.0:8080/orders"
624        // Strip leading "//"
625        let authority_and_path = parts.path.trim_start_matches('/');
626
627        // Split on the first "/" to separate "host:port" from "/path"
628        let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
629            (&authority_and_path[..idx], &authority_and_path[idx..])
630        } else {
631            (authority_and_path, "/")
632        };
633
634        let path = if path_suffix.is_empty() {
635            "/"
636        } else {
637            path_suffix
638        }
639        .to_string();
640
641        // Parse host:port from authority
642        let (host, port) = if let Some(colon) = authority.rfind(':') {
643            let port_str = &authority[colon + 1..];
644            match port_str.parse::<u16>() {
645                Ok(p) => (authority[..colon].to_string(), p),
646                Err(_) => {
647                    return Err(CamelError::InvalidUri(format!(
648                        "invalid port '{}' in authority",
649                        port_str
650                    )));
651                }
652            }
653        } else {
654            // Default port based on scheme: 443 for https, 80 for http
655            let default_port = if parts.scheme == "https" { 443 } else { 80 };
656            (authority.to_string(), default_port)
657        };
658
659        let max_request_body = parts
660            .params
661            .get("maxRequestBody")
662            .and_then(|v| v.parse::<usize>().ok())
663            .unwrap_or(2 * 1024 * 1024); // Default: 2MB
664
665        let max_response_body = parts
666            .params
667            .get("maxResponseBody")
668            .and_then(|v| v.parse::<usize>().ok())
669            .unwrap_or(10 * 1024 * 1024); // Default: 10MB
670
671        let max_inflight_requests = parts
672            .params
673            .get("maxInflightRequests")
674            .and_then(|v| v.parse::<usize>().ok())
675            .unwrap_or(1024);
676
677        // Uppercase-normalize so a hand-written `httpMethod=get` matches the
678        // uppercase method the dispatcher compares against (axum's
679        // `req.method().to_string()` yields "GET"). Without this, a
680        // lower-case `httpMethod` would never match and silently 404.
681        // Review I5.
682        let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
683
684        Ok(Self {
685            scheme: parts.scheme,
686            host,
687            port,
688            path,
689            max_request_body,
690            max_response_body,
691            max_inflight_requests,
692            method,
693            tls_config: {
694                let cert = parts.params.get("tlsCert").cloned();
695                let key = parts.params.get("tlsKey").cloned();
696                match (cert, key) {
697                    (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
698                        cert_path: c,
699                        key_path: k,
700                    }),
701                    (None, None) => None,
702                    _ => None, // partial — enforced in create_consumer, not here
703                }
704            },
705        })
706    }
707}
708
709impl HttpServerConfig {
710    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
711        let parts = parse_uri(uri)?;
712        let mut server = Self::from_components(parts.clone())?;
713        if !parts.params.contains_key("maxRequestBody") {
714            server.max_request_body = config.max_request_body;
715        }
716        if !parts.params.contains_key("maxResponseBody") {
717            // Default max_response_body is 10MB via HttpConfig::default().max_body_size.
718            server.max_response_body = config.max_body_size;
719        }
720        Ok(server)
721    }
722}
723
724// ---------------------------------------------------------------------------
725// RequestEnvelope / HttpReply
726// ---------------------------------------------------------------------------
727
728/// Body of the HTTP response: already-materialized bytes or a lazy stream.
729///
730/// **Internal plumbing** — subject to change without notice.
731pub enum HttpReplyBody {
732    Bytes(bytes::Bytes),
733    Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
734}
735
736/// An inbound HTTP request sent from the Axum dispatch handler to an
737/// `HttpConsumer` receive loop.
738///
739/// **Internal plumbing** — subject to change without notice.
740pub struct RequestEnvelope {
741    pub method: String,
742    pub path: String,
743    pub query: String,
744    pub headers: http::HeaderMap,
745    pub body: StreamBody,
746    /// Path parameters extracted from a REST template match, e.g.
747    /// `id=42` for a request to `/users/42` matched against
748    /// `/users/{id}`. Empty for non-REST requests or for literal
749    /// template matches. The consumer turns these into
750    /// `CamelHttpPath_<param>` headers on the Exchange (expert guidance E2).
751    pub path_params: std::collections::HashMap<String, String>,
752    pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
753}
754
755/// The HTTP response that `HttpConsumer` sends back to the Axum handler.
756///
757/// **Internal plumbing** — subject to change without notice.
758pub struct HttpReply {
759    pub status: u16,
760    pub headers: Vec<(String, String)>,
761    pub body: HttpReplyBody,
762}
763
764// ---------------------------------------------------------------------------
765// HttpRouteRegistry / ServerRegistry
766// ---------------------------------------------------------------------------
767
768type ServerKey = (String, u16);
769
770/// Handle to a running Axum server on one interface/port.
771struct ServerHandle {
772    registry: HttpRouteRegistry,
773    max_request_body: usize,
774    max_response_body: usize,
775    max_inflight_requests: usize,
776    is_tls: bool,
777    tls_cert_path: Option<String>,
778    tls_key_path: Option<String>,
779    /// JoinHandle for the monitor_axum_task wrapper. `is_finished()` is the
780    /// dead-server eviction signal in `get_or_spawn`.
781    monitor_task: tokio::task::JoinHandle<()>,
782    // Retained so the reload handler (Task 7) can call reload_from_config()
783    // to hot-swap certs without restarting the server.
784    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
785    tls_source: Option<ServerTlsSource>,
786}
787
788/// Process-global registry mapping (host, port) → running Axum server handle.
789pub struct ServerRegistry {
790    inner: Mutex<HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>>,
791}
792
793impl ServerRegistry {
794    /// Returns the global singleton.
795    pub fn global() -> &'static Self {
796        static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
797        INSTANCE.get_or_init(|| ServerRegistry {
798            inner: Mutex::new(HashMap::new()),
799        })
800    }
801
802    /// Returns route registry for `port`, spawning new Axum server if
803    /// none is running on that port yet.
804    #[allow(clippy::too_many_arguments)]
805    pub async fn get_or_spawn(
806        &'static self,
807        host: &str,
808        port: u16,
809        max_request_body: usize,
810        max_response_body: usize,
811        max_inflight_requests: usize,
812        runtime: Arc<dyn RuntimeObservability>,
813        route_id: String,
814        tls_config: Option<crate::config::ServerTlsConfig>,
815    ) -> Result<HttpRouteRegistry, CamelError> {
816        let host_owned = host.to_string();
817
818        let cell = {
819            let mut guard = self.inner.lock().map_err(|_| {
820                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
821            })?;
822            let key = (host.to_string(), port);
823            // Evict dead server so a fresh one can spawn (matches gRPC D-L2 pattern).
824            // The monitor task awaits the server task, so monitor_task.is_finished()
825            // is a reliable proxy for the server being gone (either crashed or aborted).
826            if let Some(existing) = guard.get(&key)
827                && let Some(handle) = existing.get()
828                && handle.monitor_task.is_finished()
829            {
830                // Deregister TLS reload handler so a respawned HTTPS server
831                // doesn't reload stale cert config from the crashed handler.
832                if handle.is_tls {
833                    let scheme = if handle.is_tls { "https" } else { "http" };
834                    camel_component_api::tls_source::TlsReloadRegistry::global()
835                        .unregister(scheme, host, port);
836                }
837                guard.remove(&key);
838            }
839            guard
840                .entry(key)
841                .or_insert_with(|| Arc::new(OnceCell::new()))
842                .clone()
843        };
844
845        if let Some(existing) = cell.get()
846            && existing.max_request_body != max_request_body
847        {
848            return Err(CamelError::EndpointCreationFailed(format!(
849                "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
850                existing.max_request_body, max_request_body
851            )));
852        }
853
854        if let Some(existing) = cell.get()
855            && existing.max_response_body != max_response_body
856        {
857            return Err(CamelError::EndpointCreationFailed(format!(
858                "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
859                existing.max_response_body, max_response_body
860            )));
861        }
862
863        if let Some(existing) = cell.get()
864            && existing.max_inflight_requests != max_inflight_requests
865        {
866            return Err(CamelError::EndpointCreationFailed(format!(
867                "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
868                existing.max_inflight_requests, max_inflight_requests
869            )));
870        }
871
872        // TLS mode mismatch: plain vs TLS
873        if let Some(existing) = cell.get()
874            && existing.is_tls != tls_config.is_some()
875        {
876            return Err(CamelError::EndpointCreationFailed(format!(
877                "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
878                existing.is_tls,
879                tls_config.is_some()
880            )));
881        }
882
883        // TLS cert/key mismatch: different cert on same TLS port
884        if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
885            && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
886                || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
887        {
888            return Err(CamelError::EndpointCreationFailed(format!(
889                "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
890            )));
891        }
892
893        let handle = cell
894            .get_or_try_init(|| {
895                let rt = Arc::clone(&runtime);
896                let rid = route_id.clone();
897                async move {
898                    let addr = format!("{host_owned}:{port}");
899                    let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
900                        CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
901                    })?;
902                    let registry = HttpRouteRegistry::new();
903                    let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
904                    // Constructed once in the TLS branch so they can be retained
905                    // on ServerHandle for the reload handler (Task 7).
906                    let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
907                    let tls_source: Option<ServerTlsSource>;
908                    let server_task = if let Some(ref tls) = tls_config {
909                        let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
910                        let source = ServerTlsSource {
911                            cert_path: std::path::PathBuf::from(&tls.cert_path),
912                            key_path: std::path::PathBuf::from(&tls.key_path),
913                            client_ca_path: None,
914                        };
915                        // Build the RustlsConfig once — clone() is cheap (Arc
916                        // internally) and shares the ArcSwap the reload handler
917                        // will mutate via reload_from_config().
918                        let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_config(
919                            std::sync::Arc::new(rustls_config),
920                        );
921                        tls_rustls_cfg = Some(rustls_cfg.clone());
922                        tls_source = Some(source);
923                        // Convert tokio listener to std for axum-server
924                        let std_listener = listener.into_std().map_err(|e| {
925                            CamelError::EndpointCreationFailed(format!(
926                                "TLS listener conversion: {e}"
927                            ))
928                        })?;
929                        tokio::spawn(run_axum_server_tls(
930                            std_listener,
931                            rustls_cfg,
932                            registry.clone(),
933                            max_request_body,
934                            max_response_body,
935                            Arc::clone(&inflight),
936                            Arc::clone(&rt),
937                            rid.clone(),
938                        ))
939                    } else {
940                        tls_rustls_cfg = None;
941                        tls_source = None;
942                        tokio::spawn(run_axum_server(
943                            listener,
944                            registry.clone(),
945                            max_request_body,
946                            max_response_body,
947                            Arc::clone(&inflight),
948                            Arc::clone(&rt),
949                            rid.clone(),
950                        ))
951                    };
952                    let addr_for_monitor = format!("{host_owned}:{port}");
953                    let monitor_task = tokio::spawn(monitor_axum_task(
954                        server_task,
955                        addr_for_monitor,
956                        Arc::clone(&rt),
957                        rid,
958                    ));
959                    let handle = ServerHandle {
960                        registry,
961                        max_request_body,
962                        max_response_body,
963                        max_inflight_requests,
964                        is_tls: tls_config.is_some(),
965                        tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
966                        tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
967                        monitor_task,
968                        tls_config: tls_rustls_cfg,
969                        tls_source,
970                    };
971                    // Register reload handler (exactly-once: inside OnceCell init closure).
972                    // Note: HTTP servers are process-lifetime (no release/eviction path),
973                    // so handlers are never unregistered. If eviction is added later,
974                    // add TlsReloadRegistry::global().unregister() there.
975                    if let (Some(tls_cfg), Some(source)) =
976                        (handle.tls_config.as_ref(), handle.tls_source.as_ref())
977                    {
978                        let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
979                            tls_cfg.clone(),
980                            source.clone(),
981                            host_owned.clone(),
982                            port,
983                        ));
984                        camel_component_api::tls_source::TlsReloadRegistry::global()
985                            .register(handler);
986                    }
987                    Ok::<ServerHandle, CamelError>(handle)
988                }
989            })
990            .await?;
991
992        Ok(handle.registry.clone())
993    }
994
995    /// Unregister one consumer from a server. HTTP servers are process-lifetime:
996    /// the server stays in the registry for potential restart. Path
997    /// deregistration happens separately in the consumer's cleanup.
998    pub async fn unregister(&self, host: &str, port: u16) {
999        debug!(
1000            host = host,
1001            port = port,
1002            "consumer unregistered from HTTP server"
1003        );
1004    }
1005
1006    /// Reset the global registry — **test-only**.
1007    ///
1008    /// Clears all registered server handles so that tests can start from a clean
1009    /// state. This is intentionally `#[cfg(test)]` because the registry is a
1010    /// process-global singleton in production and resetting it would break
1011    /// running servers.
1012    #[cfg(test)]
1013    pub fn reset() {
1014        let instance = Self::global();
1015        let mut guard = instance
1016            .inner
1017            .lock()
1018            .expect("ServerRegistry lock poisoned during test reset");
1019        guard.clear();
1020    }
1021}
1022
1023// ---------------------------------------------------------------------------
1024// Axum server
1025// ---------------------------------------------------------------------------
1026
1027use axum::{
1028    Router,
1029    body::Body as AxumBody,
1030    extract::{Request, State},
1031    http::{Response, StatusCode},
1032    response::IntoResponse,
1033};
1034
1035#[derive(Clone)]
1036pub(crate) struct AppState {
1037    registry: HttpRouteRegistry,
1038    max_request_body: usize,
1039    max_response_body: usize,
1040    inflight: Arc<tokio::sync::Semaphore>,
1041}
1042
1043async fn run_axum_server(
1044    listener: tokio::net::TcpListener,
1045    registry: HttpRouteRegistry,
1046    max_request_body: usize,
1047    max_response_body: usize,
1048    inflight: Arc<tokio::sync::Semaphore>,
1049    runtime: Arc<dyn RuntimeObservability>,
1050    route_id: String,
1051) {
1052    let state = AppState {
1053        registry,
1054        max_request_body,
1055        max_response_body,
1056        inflight,
1057    };
1058    let app = Router::new().fallback(dispatch_handler).with_state(state);
1059
1060    axum::serve(listener, app).await.unwrap_or_else(|e| {
1061        runtime
1062            .metrics()
1063            .increment_errors(&route_id, "e:http:accept");
1064        // log-policy: outside-contract
1065        tracing::error!(error = %e, "Axum server error");
1066    });
1067}
1068
1069#[allow(clippy::too_many_arguments)]
1070async fn run_axum_server_tls(
1071    listener: std::net::TcpListener,
1072    tls_cfg: axum_server::tls_rustls::RustlsConfig,
1073    registry: HttpRouteRegistry,
1074    max_request_body: usize,
1075    max_response_body: usize,
1076    inflight: Arc<tokio::sync::Semaphore>,
1077    runtime: Arc<dyn RuntimeObservability>,
1078    route_id: String,
1079) {
1080    let state = AppState {
1081        registry,
1082        max_request_body,
1083        max_response_body,
1084        inflight,
1085    };
1086    let app = Router::new().fallback(dispatch_handler).with_state(state);
1087
1088    // RustlsConfig is now constructed once in get_or_spawn and retained on
1089    // ServerHandle so the reload handler can call reload_from_config() on it.
1090
1091    axum_server::from_tcp_rustls(listener, tls_cfg)
1092        .serve(app.into_make_service())
1093        .await
1094        .unwrap_or_else(|e| {
1095            runtime
1096                .metrics()
1097                .increment_errors(&route_id, "e:http:accept-tls");
1098            // log-policy: outside-contract
1099            tracing::error!(error = %e, "Axum TLS server error");
1100        });
1101}
1102
1103/// Monitors an Axum server task and emits a structured error event if it
1104/// exits unexpectedly.
1105///
1106/// # Limitations
1107/// The HTTP server is shared across all routes on a port. Full per-route
1108/// CrashNotification propagation is deferred — this provides observable
1109/// structured logging as a first guard.
1110async fn monitor_axum_task(
1111    handle: tokio::task::JoinHandle<()>,
1112    addr: String,
1113    runtime: Arc<dyn RuntimeObservability>,
1114    route_id: String,
1115) {
1116    match handle.await {
1117        Ok(()) => {
1118            // Clean exit (process shutdown or normal stop)
1119        }
1120        Err(join_err) => {
1121            runtime
1122                .metrics()
1123                .increment_errors(&route_id, "e:http:server-task-exited");
1124            // log-policy: outside-contract
1125            tracing::error!(
1126                addr = %addr,
1127                error = %join_err,
1128                "Axum server task exited unexpectedly — all routes on this port are now dead"
1129            );
1130        }
1131    }
1132}
1133
1134/// Load a rustls ServerConfig from PEM cert/key files.
1135/// Adapted from camel-ws lib.rs load_tls_config.
1136fn load_tls_config(
1137    cert_path: &str,
1138    key_path: &str,
1139) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1140    use std::fs::File;
1141    use std::io::BufReader;
1142
1143    let cert_file = File::open(cert_path)
1144        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1145    let key_file = File::open(key_path)
1146        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1147
1148    let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1149        .collect::<Result<Vec<_>, _>>()
1150        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1151
1152    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1153        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1154        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1155
1156    tokio_rustls::rustls::ServerConfig::builder()
1157        .with_no_client_auth()
1158        .with_single_cert(certs, key)
1159        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1160}
1161
1162async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1163    let path = req.uri().path().to_owned();
1164    let method = req.method().to_string();
1165
1166    // Dispatch precedence (spec §7.2 / ADR-0009):
1167    //   1. Exact API path match (legacy `http:` routes without httpMethod)
1168    //   2. Templated API path match (REST, method-aware, by specificity)
1169    //   3. Static mount longest-prefix
1170    //   4. SPA fallback
1171    //
1172    // Legacy exact runs first: it is a cheap HashMap get, and the two
1173    // registries are mutually exclusive per route — a legacy route carries
1174    // no `httpMethod` and lives only in `api_routes`, while a REST-lowered
1175    // route carries `httpMethod` and lives only in `rest_endpoints`. So an
1176    // exact hit can never shadow a REST route that should have matched,
1177    // and running exact-first honours the documented precedence (the prior
1178    // REST-first order let a templated `GET /api/{resource}` steal a
1179    // request meant for an exact `GET /api/users`). Intra-REST method
1180    // disambiguation is handled inside `match_endpoint`, not by this
1181    // ordering. Review C2.
1182    let api_sender = {
1183        let inner = state.registry.inner.read().await;
1184        inner.api_routes.get(&path).cloned()
1185    }; // lock released BEFORE any IO
1186
1187    let (rest_sender, path_params) = if api_sender.is_some() {
1188        // Exact legacy match won — skip the templated scan entirely.
1189        (None, Default::default())
1190    } else {
1191        let inner = state.registry.inner.read().await;
1192        match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1193            rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1194            rest_match::MatchOutcome::Ambiguous => {
1195                // Ambiguous registration should have been rejected at
1196                // lowering time (rest.rs). Reaching here means two
1197                // equal-specificity templates matched one request —
1198                // surface a loud error rather than a silent 404. Review C3.
1199                // log-policy: handler-owned
1200                tracing::warn!(
1201                    method = %method,
1202                    path = %path,
1203                    "ambiguous REST template match — returning 500"
1204                );
1205                return Response::builder()
1206                    .status(StatusCode::INTERNAL_SERVER_ERROR)
1207                    .body(AxumBody::from("Internal Server Error"))
1208                    .expect("infallible"); // allow-unwrap
1209            }
1210            rest_match::MatchOutcome::NotFound => (None, Default::default()),
1211        }
1212    }; // lock released BEFORE any IO
1213
1214    let sender = api_sender.or(rest_sender);
1215
1216    if let Some(sender) = sender {
1217        let query = req.uri().query().unwrap_or("").to_string();
1218        let headers = req.headers().clone();
1219
1220        // Check Content-Length against limit BEFORE opening the stream
1221        let content_length: Option<u64> = headers
1222            .get(http::header::CONTENT_LENGTH)
1223            .and_then(|v| v.to_str().ok())
1224            .and_then(|s| s.parse().ok());
1225
1226        if let Some(len) = content_length
1227            && len > state.max_request_body as u64
1228        {
1229            return Response::builder()
1230                .status(StatusCode::PAYLOAD_TOO_LARGE)
1231                .body(AxumBody::from("Request body exceeds configured limit"))
1232                .expect("infallible"); // allow-unwrap
1233        }
1234
1235        let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1236            Ok(permit) => permit,
1237            Err(_) => {
1238                return Response::builder()
1239                    .status(StatusCode::SERVICE_UNAVAILABLE)
1240                    .body(AxumBody::from("Service Unavailable"))
1241                    .expect("infallible"); // allow-unwrap
1242            }
1243        };
1244
1245        // Build StreamBody from Axum body WITHOUT materializing
1246        let content_type = headers
1247            .get(http::header::CONTENT_TYPE)
1248            .and_then(|v| v.to_str().ok())
1249            .map(|s| s.to_string());
1250
1251        let data_stream: BodyDataStream = req.into_body().into_data_stream();
1252        let mapped_stream = data_stream.map_err(|e| CamelError::Io(e.to_string()));
1253        let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(mapped_stream);
1254
1255        let stream_body = StreamBody {
1256            stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1257            metadata: StreamMetadata {
1258                size_hint: content_length,
1259                content_type,
1260                origin: None,
1261            },
1262        };
1263
1264        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1265        let envelope = RequestEnvelope {
1266            method,
1267            path,
1268            query,
1269            headers,
1270            body: stream_body,
1271            path_params,
1272            reply_tx,
1273        };
1274
1275        if sender.send(envelope).await.is_err() {
1276            return Response::builder()
1277                .status(StatusCode::SERVICE_UNAVAILABLE)
1278                .body(AxumBody::from("Consumer unavailable"))
1279                .expect("infallible"); // allow-unwrap
1280        }
1281
1282        match reply_rx.await {
1283            Ok(reply) => {
1284                let reply = match reply.body {
1285                    HttpReplyBody::Bytes(b)
1286                        if exceeds_max_response_body(b.len(), state.max_response_body) =>
1287                    {
1288                        HttpReply {
1289                            status: 500,
1290                            headers: vec![],
1291                            body: HttpReplyBody::Bytes(bytes::Bytes::from(
1292                                "Response body exceeds configured limit",
1293                            )),
1294                        }
1295                    }
1296                    _ => reply,
1297                };
1298
1299                let status =
1300                    StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1301                let mut builder = Response::builder().status(status);
1302                for (k, v) in &reply.headers {
1303                    builder = builder.header(k.as_str(), v.as_str());
1304                }
1305                match reply.body {
1306                    HttpReplyBody::Bytes(b) => {
1307                        builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1308                            Response::builder()
1309                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1310                                .body(AxumBody::from("Invalid response headers from consumer"))
1311                                .expect("infallible") // allow-unwrap
1312                        })
1313                    }
1314                    HttpReplyBody::Stream(stream) => builder
1315                        .body(AxumBody::from_stream(stream))
1316                        .unwrap_or_else(|_| {
1317                            Response::builder()
1318                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1319                                .body(AxumBody::from("Invalid response headers from consumer"))
1320                                .expect("infallible") // allow-unwrap
1321                        }),
1322                }
1323            }
1324            Err(_) => Response::builder()
1325                .status(StatusCode::INTERNAL_SERVER_ERROR)
1326                .body(AxumBody::from("Pipeline error"))
1327                .expect("infallible"), // allow-unwrap
1328        }
1329    } else {
1330        // No API route matched — try static mounts
1331        static_dispatch::dispatch_static(&state, req, &path).await
1332    }
1333}
1334
1335fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1336    len > max
1337}
1338
1339fn title_case_header(name: &str) -> String {
1340    name.split('-')
1341        .map(|part| {
1342            let mut chars = part.chars();
1343            match chars.next() {
1344                None => String::new(),
1345                Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1346            }
1347        })
1348        .collect::<Vec<_>>()
1349        .join("-")
1350}
1351
1352// ---------------------------------------------------------------------------
1353// HttpConsumer
1354// ---------------------------------------------------------------------------
1355
1356/// Capacity for the per-route RequestEnvelope channel.
1357///
1358/// Each in-flight request holds exactly one `maxInflightRequests` semaphore
1359/// permit from before `send()` until its reply, so at most N envelopes can be
1360/// outstanding at any time. A buffer of N therefore can never fill before the
1361/// semaphore exhausts: dispatcher `send()` calls never park on a full buffer
1362/// and the semaphore stays the single, URI-configurable backpressure point.
1363/// A hardcoded smaller buffer would act as a second, hidden inflight cap
1364/// (rc-3y6j: 64 vs default 1024 permits).
1365///
1366/// `max(1)`: `maxInflightRequests=0` is a representable "reject everything"
1367/// configuration, but `tokio::sync::mpsc::channel(0)` panics — keep consumer
1368/// start panic-free (the empty semaphore still 503s every request).
1369fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1370    max_inflight_requests.max(1)
1371}
1372
1373pub struct HttpConsumer {
1374    config: HttpServerConfig,
1375    /// Runtime observability handle for ADR-0012 metrics and health calls.
1376    runtime: Arc<dyn RuntimeObservability>,
1377}
1378
1379impl HttpConsumer {
1380    pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1381        Self { config, runtime }
1382    }
1383}
1384
1385#[async_trait::async_trait]
1386impl Consumer for HttpConsumer {
1387    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1388        use camel_component_api::{Body, Exchange, Message};
1389
1390        let registry = ServerRegistry::global()
1391            .get_or_spawn(
1392                &self.config.host,
1393                self.config.port,
1394                self.config.max_request_body,
1395                self.config.max_response_body,
1396                self.config.max_inflight_requests,
1397                self.runtime.clone(),
1398                ctx.route_id().to_string(),
1399                self.config.tls_config.clone(),
1400            )
1401            .await?;
1402
1403        // Create channel for this path and register it. Capacity matches the
1404        // dispatcher's inflight semaphore (see envelope_channel_capacity) so
1405        // the channel can never become a second backpressure point.
1406        let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1407            envelope_channel_capacity(self.config.max_inflight_requests),
1408        );
1409        // When the from-URI carries `httpMethod=...` (REST-lowered
1410        // route), register the consumer as a method-aware REST endpoint
1411        // so the dispatcher can route by (method, path template).
1412        // Otherwise fall back to the legacy path-only api_routes
1413        // registry. The two registries never overlap for the same
1414        // route: each consumer registers in exactly one of them.
1415        if let Some(method) = self.config.method.clone() {
1416            let segments = rest_match::parse_path_template(&self.config.path);
1417            registry
1418                .register_rest_endpoint(method, segments, env_tx)
1419                .await;
1420        } else {
1421            registry
1422                .register_api_route(self.config.path.clone(), env_tx)
1423                .await;
1424        }
1425
1426        // rc-w1u9: Signal readiness AFTER (1) TcpListener::bind succeeded
1427        // (inside get_or_spawn above), (2) the axum server task was spawned,
1428        // and (3) this route's path/REST endpoint was registered. At this
1429        // point the listener is genuinely accepting connections and any
1430        // request to this route will be dispatched (not 404'd). The runtime
1431        // uses this signal to publish RouteStarted and to release
1432        // ctx.start() so external benchmarks can emit a reliable
1433        // listener-bound marker.
1434        ctx.mark_ready();
1435
1436        let path = self.config.path.clone();
1437        let registry_for_cleanup = registry.clone();
1438        let cancel_token = ctx.cancel_token();
1439        loop {
1440            tokio::select! {
1441                _ = ctx.cancelled() => {
1442                    break;
1443                }
1444                envelope = env_rx.recv() => {
1445                    let Some(envelope) = envelope else { break; };
1446
1447                    // Build Exchange from HTTP request
1448                    let mut msg = Message::default();
1449
1450                    // Set standard Camel HTTP headers
1451                    msg.set_header("CamelHttpMethod",
1452                        serde_json::Value::String(envelope.method.clone()));
1453                    msg.set_header("CamelHttpPath",
1454                        serde_json::Value::String(envelope.path.clone()));
1455                    msg.set_header("CamelHttpQuery",
1456                        serde_json::Value::String(envelope.query.clone()));
1457
1458                    // Set path-parameter headers from REST template
1459                    // match. Expert guidance E2: the consumer is
1460                    // responsible for translating the dispatcher's
1461                    // matched params into `CamelHttpPath_<param>`
1462                    // headers on the Exchange, matching the convention
1463                    // used by Camel HTTP for templated routes.
1464                    for (param_name, param_value) in &envelope.path_params {
1465                        msg.set_header(
1466                            format!("CamelHttpPath_{param_name}"),
1467                            serde_json::Value::String(param_value.clone()),
1468                        );
1469                    }
1470
1471                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
1472                    for (k, v) in &envelope.headers {
1473                        if let Ok(val_str) = v.to_str() {
1474                            msg.set_header(
1475                                title_case_header(k.as_str()),
1476                                serde_json::Value::String(val_str.to_string()),
1477                            );
1478                        }
1479                    }
1480
1481                    // Body: always arrives as Body::Stream (native streaming)
1482                    // Routes can call into_bytes() if they need to materialize
1483                    msg.body = Body::Stream(envelope.body);
1484
1485                    #[allow(unused_mut)]
1486                    let mut exchange = Exchange::new(msg);
1487
1488                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1489                    #[cfg(feature = "otel")]
1490                    {
1491                        let headers: HashMap<String, String> = envelope
1492                            .headers
1493                            .iter()
1494                            .filter_map(|(k, v)| {
1495                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1496                            })
1497                            .collect();
1498                        camel_otel::extract_into_exchange(&mut exchange, &headers);
1499                    }
1500
1501                    let reply_tx = envelope.reply_tx;
1502                    let sender = ctx.sender().clone();
1503                    let path_clone = path.clone();
1504                    let cancel = cancel_token.clone();
1505
1506                    // Spawn a task to handle this request concurrently
1507                    //
1508                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
1509                    // true concurrent request processing. This change was introduced as part of the
1510                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
1511                    //
1512                    // Rationale:
1513                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
1514                    //    the consumer's main loop until the pipeline processing completes
1515                    // 2. This blocking would prevent multiple HTTP requests from being processed
1516                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
1517                    // 3. The channel would never have multiple exchanges buffered simultaneously,
1518                    //    defeating the purpose of pipeline-side concurrency
1519                    // 4. By spawning a task per request, we allow the consumer loop to continue
1520                    //    accepting new requests while existing ones are processed in the pipeline
1521                    //
1522                    // This approach effectively decouples request acceptance from pipeline processing,
1523                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
1524                    // by the pipeline when ConcurrencyModel::Concurrent is active.
1525                    tokio::spawn(async move {
1526                        // Check for cancellation before sending to pipeline.
1527                        // Returns 503 (Service Unavailable) instead of letting the request
1528                        // enter a shutting-down pipeline. This is a behavioral change from
1529                        // the pre-concurrency implementation where cancellation during
1530                        // processing would result in a 500 (Internal Server Error).
1531                        // 503 is more semantically correct: the server is temporarily
1532                        // unable to handle the request due to shutdown.
1533                        if cancel.is_cancelled() {
1534                            let _ = reply_tx.send(HttpReply {
1535                                status: 503,
1536                                headers: vec![],
1537                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1538                            });
1539                            return;
1540                        }
1541
1542                        // Send through pipeline and await result
1543                        let (tx, rx) = tokio::sync::oneshot::channel();
1544                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
1545                            exchange,
1546                            reply_tx: Some(tx),
1547                        };
1548
1549                        let result = match sender.send(envelope).await {
1550                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1551                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1552                        }
1553                        .and_then(|r| r);
1554
1555                        let reply = match result {
1556                            Ok(out) => {
1557                                let status = out
1558                                    .input
1559                                    .header("CamelHttpResponseCode")
1560                                    .and_then(|v| {
1561                                        let raw = v.as_u64()
1562                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1563                                        let code = raw as u16;
1564                                        (100..1000).contains(&code).then_some(code)
1565                                    })
1566                                    .unwrap_or(200);
1567
1568                                let user_content_type = out
1569                                    .input
1570                                    .header("Content-Type")
1571                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
1572
1573                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1574                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1575                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1576                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1577                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1578                                        v.to_string().into_bytes(),
1579                                    )), Some("application/json".to_string())),
1580                                    Body::Stream(s) => {
1581                                        let ct = s.metadata.content_type.clone();
1582                                        match s.stream.lock().await.take() {
1583                                            Some(stream) => (
1584                                                HttpReplyBody::Stream(stream),
1585                                                ct,
1586                                            ),
1587                                            None => {
1588                                                // log-policy: system-broken
1589                                                tracing::error!(
1590                                                    "Body::Stream already consumed before HTTP reply — returning 500"
1591                                                );
1592                                                let error_reply = HttpReply {
1593                                                    status: 500,
1594                                                    headers: vec![],
1595                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1596                                                };
1597                                                if reply_tx.send(error_reply).is_err() {
1598                                                    debug!("reply_tx dropped before error reply could be sent");
1599                                                }
1600                                                return;
1601                                            }
1602                                        }
1603                                    }
1604                                    // Empty and future variants produce an empty reply body.
1605                                    _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1606                                };
1607
1608                                let resp_headers = select_response_headers(
1609                                    &out.input.headers,
1610                                    user_content_type,
1611                                    inferred_content_type,
1612                                );
1613
1614                                HttpReply {
1615                                    status,
1616                                    headers: resp_headers,
1617                                    body: reply_body,
1618                                }
1619                            }
1620                            Err(e) => {
1621                                pipeline_error_to_reply(e, &path_clone)
1622                            }
1623                        };
1624
1625                        // Reply to Axum handler (ignore error if client disconnected)
1626                        let _ = reply_tx.send(reply);
1627                    });
1628                }
1629            }
1630        }
1631
1632        // Deregister this consumer. Mirror the registration choice:
1633        // REST-registered consumers remove their (method, path) endpoint
1634        // WITHOUT touching sibling verbs on the same template (review C1);
1635        // legacy consumers clean up api_routes.
1636        if let Some(method) = &self.config.method {
1637            registry_for_cleanup
1638                .unregister_rest_endpoint(method, &path)
1639                .await;
1640        } else {
1641            registry_for_cleanup.unregister_api_route(&path).await;
1642        }
1643
1644        // D-L10: decrement the shared server's refcount. When the last
1645        // consumer on this (host, port) leaves, the server + monitor tasks
1646        // are aborted and the registry entry is removed.
1647        ServerRegistry::global()
1648            .unregister(&self.config.host, self.config.port)
1649            .await;
1650
1651        Ok(())
1652    }
1653
1654    async fn stop(&mut self) -> Result<(), CamelError> {
1655        Ok(())
1656    }
1657
1658    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1659        camel_component_api::ConcurrencyModel::Concurrent { max: None }
1660    }
1661
1662    // rc-w1u9: HTTP consumer binds a TcpListener inside start() (via
1663    // ServerRegistry::get_or_spawn) and only THEN can it accept connections.
1664    // Opting into Explicit startup makes ctx.start() await the bind+register
1665    // completion so listeners fail fast on bind errors (previously a silent
1666    // background log) and external markers can reliably detect listener-bound
1667    // state.
1668    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
1669        camel_component_api::ConsumerStartupMode::Explicit
1670    }
1671}
1672
1673// ---------------------------------------------------------------------------
1674// HttpComponent / HttpsComponent
1675// ---------------------------------------------------------------------------
1676
1677pub struct HttpComponent {
1678    config: HttpConfig,
1679}
1680
1681pub(crate) fn build_client(
1682    config: &HttpConfig,
1683    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
1684) -> reqwest::Client {
1685    let mut builder = reqwest::Client::builder()
1686        .no_proxy() // CRITICAL: env proxies bypass resolve_to_addrs
1687        .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1688        .pool_max_idle_per_host(config.pool_max_idle_per_host)
1689        .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1690
1691    // Redirects are always handled manually in the producer's send path
1692    // so that each hop can be SSRF-validated. reqwest's built-in redirect
1693    // policy is sync and cannot perform async DNS resolution or SSRF checks.
1694    builder = builder.redirect(reqwest::redirect::Policy::none());
1695
1696    if let Some((host, addrs)) = resolve_override {
1697        builder = builder.resolve_to_addrs(host, addrs);
1698    }
1699
1700    if let Some(tls) = &config.tls
1701        && tls.enabled
1702    {
1703        if tls.insecure || !tls.verify_peer {
1704            // log-policy: handler-owned
1705            tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
1706            builder = builder.danger_accept_invalid_certs(true);
1707        }
1708
1709        if let Some(ca_path) = &tls.ca_cert_path
1710            && let Ok(ca_bytes) = std::fs::read(ca_path)
1711        {
1712            let cert = reqwest::Certificate::from_pem(&ca_bytes)
1713                .or_else(|_| reqwest::Certificate::from_der(&ca_bytes));
1714            if let Ok(ca_cert) = cert {
1715                builder = builder.add_root_certificate(ca_cert);
1716            }
1717        }
1718
1719        if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path)
1720            && let (Ok(cert_bytes), Ok(key_bytes)) =
1721                (std::fs::read(cert_path), std::fs::read(key_path))
1722        {
1723            let mut identity_pem = cert_bytes;
1724            identity_pem.extend_from_slice(&key_bytes);
1725            if let Ok(identity) = reqwest::Identity::from_pem(&identity_pem) {
1726                builder = builder.identity(identity);
1727            }
1728        }
1729    }
1730
1731    builder
1732        .build()
1733        .expect("reqwest::Client::build() with valid config should not fail") // allow-unwrap
1734}
1735
1736impl HttpComponent {
1737    pub fn new() -> Self {
1738        let config = HttpConfig::default();
1739        Self { config }
1740    }
1741
1742    pub fn with_config(config: HttpConfig) -> Self {
1743        Self { config }
1744    }
1745
1746    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1747        match config {
1748            Some(cfg) => Self::with_config(cfg),
1749            None => Self::new(),
1750        }
1751    }
1752}
1753
1754impl Default for HttpComponent {
1755    fn default() -> Self {
1756        Self::new()
1757    }
1758}
1759
1760impl Component for HttpComponent {
1761    fn scheme(&self) -> &str {
1762        "http"
1763    }
1764
1765    fn metadata(&self) -> ComponentMetadata {
1766        HttpEndpointConfig::metadata()
1767    }
1768
1769    fn create_endpoint(
1770        &self,
1771        uri: &str,
1772        ctx: &dyn camel_component_api::ComponentContext,
1773    ) -> Result<Box<dyn Endpoint>, CamelError> {
1774        self.config.validate()?;
1775        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1776        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1777        let client = build_client(&self.config, None);
1778        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1779            server_config.host.clone(),
1780            server_config.port,
1781        )));
1782        Ok(Box::new(HttpEndpoint {
1783            uri: uri.to_string(),
1784            config,
1785            server_config,
1786            client,
1787            http_config: self.config.clone(),
1788        }))
1789    }
1790}
1791
1792pub struct HttpsComponent {
1793    config: HttpConfig,
1794}
1795
1796impl HttpsComponent {
1797    pub fn new() -> Self {
1798        let config = HttpConfig::default();
1799        Self { config }
1800    }
1801
1802    pub fn with_config(config: HttpConfig) -> Self {
1803        Self { config }
1804    }
1805
1806    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1807        match config {
1808            Some(cfg) => Self::with_config(cfg),
1809            None => Self::new(),
1810        }
1811    }
1812}
1813
1814impl Default for HttpsComponent {
1815    fn default() -> Self {
1816        Self::new()
1817    }
1818}
1819
1820impl Component for HttpsComponent {
1821    fn scheme(&self) -> &str {
1822        "https"
1823    }
1824
1825    fn metadata(&self) -> ComponentMetadata {
1826        // HTTPS shares the same URI option surface and capabilities as HTTP.
1827        // Only the scheme and description differ.
1828        let mut meta = HttpEndpointConfig::metadata();
1829        meta.scheme = "https".to_string();
1830        meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
1831        meta
1832    }
1833
1834    fn create_endpoint(
1835        &self,
1836        uri: &str,
1837        ctx: &dyn camel_component_api::ComponentContext,
1838    ) -> Result<Box<dyn Endpoint>, CamelError> {
1839        self.config.validate()?;
1840        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1841        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1842        let client = build_client(&self.config, None);
1843        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1844            server_config.host.clone(),
1845            server_config.port,
1846        )));
1847        Ok(Box::new(HttpEndpoint {
1848            uri: uri.to_string(),
1849            config,
1850            server_config,
1851            client,
1852            http_config: self.config.clone(),
1853        }))
1854    }
1855}
1856
1857// ---------------------------------------------------------------------------
1858// HttpEndpoint
1859// ---------------------------------------------------------------------------
1860
1861struct HttpEndpoint {
1862    uri: String,
1863    config: HttpEndpointConfig,
1864    server_config: HttpServerConfig,
1865    client: reqwest::Client,
1866    http_config: HttpConfig,
1867}
1868
1869impl Endpoint for HttpEndpoint {
1870    fn uri(&self) -> &str {
1871        &self.uri
1872    }
1873
1874    fn create_consumer(
1875        &self,
1876        rt: Arc<dyn camel_component_api::RuntimeObservability>,
1877    ) -> Result<Box<dyn Consumer>, CamelError> {
1878        // Scheme/config consistency check (spec §5) — uses parsed scheme
1879        // from HttpServerConfig, not a fragile port-443 heuristic.
1880        let scheme_is_https = self.server_config.scheme == "https";
1881        let has_tls = self.server_config.tls_config.is_some();
1882
1883        if scheme_is_https && !has_tls {
1884            return Err(CamelError::EndpointCreationFailed(
1885                "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
1886            ));
1887        }
1888        if !scheme_is_https && has_tls {
1889            return Err(CamelError::EndpointCreationFailed(
1890                "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
1891            ));
1892        }
1893        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
1894    }
1895
1896    fn create_producer(
1897        &self,
1898        _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1899        _ctx: &ProducerContext,
1900    ) -> Result<BoxProcessor, CamelError> {
1901        let producer = HttpProducer {
1902            config: Arc::new(self.config.clone()),
1903            client: self.client.clone(),
1904            http_config: Arc::new(self.http_config.clone()),
1905        };
1906        if let Some(ref provider) = self.config.token_provider {
1907            let layer = BearerTokenLayer::new(Arc::clone(provider));
1908            Ok(BoxProcessor::new(layer.layer(producer)))
1909        } else {
1910            Ok(BoxProcessor::new(producer))
1911        }
1912    }
1913}
1914
1915// ---------------------------------------------------------------------------
1916// HttpProducer
1917// ---------------------------------------------------------------------------
1918
1919#[derive(Clone)]
1920struct HttpProducer {
1921    config: Arc<HttpEndpointConfig>,
1922    client: reqwest::Client,
1923    http_config: Arc<HttpConfig>,
1924}
1925
1926impl HttpProducer {
1927    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1928        if let Some(ref method) = config.http_method {
1929            return method.to_uppercase();
1930        }
1931        if let Some(method) = exchange
1932            .input
1933            .header("CamelHttpMethod")
1934            .and_then(|v| v.as_str())
1935        {
1936            return method.to_uppercase();
1937        }
1938        if !exchange.input.body.is_empty() {
1939            return "POST".to_string();
1940        }
1941        "GET".to_string()
1942    }
1943
1944    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1945        // bridgeEndpoint=true: emit the endpoint base URL verbatim and ignore
1946        // ALL exchange URL headers (CamelHttpUri, CamelHttpPath,
1947        // CamelHttpQuery) per Apache Camel bridging semantics. Only
1948        // configured query_params are applied. This check MUST come before the
1949        // CamelHttpUri override so bridging wins over that header.
1950        if config.bridge_endpoint {
1951            let url = config.base_url.clone();
1952            if config.query_params.is_empty() {
1953                return url;
1954            }
1955            let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); // allow-unwrap
1956            for (k, v) in &config.query_params {
1957                parsed.query_pairs_mut().append_pair(k, v);
1958            }
1959            return parsed.to_string();
1960        }
1961
1962        if let Some(uri) = exchange
1963            .input
1964            .header("CamelHttpUri")
1965            .and_then(|v| v.as_str())
1966        {
1967            let mut url = uri.to_string();
1968            if let Some(path) = exchange
1969                .input
1970                .header("CamelHttpPath")
1971                .and_then(|v| v.as_str())
1972            {
1973                if !url.ends_with('/') && !path.starts_with('/') {
1974                    url.push('/');
1975                }
1976                url.push_str(path);
1977            }
1978            if let Some(query) = exchange
1979                .input
1980                .header("CamelHttpQuery")
1981                .and_then(|v| v.as_str())
1982            {
1983                url.push('?');
1984                url.push_str(query);
1985            }
1986            return url;
1987        }
1988
1989        let mut url = config.base_url.clone();
1990
1991        if let Some(path) = exchange
1992            .input
1993            .header("CamelHttpPath")
1994            .and_then(|v| v.as_str())
1995        {
1996            if !url.ends_with('/') && !path.starts_with('/') {
1997                url.push('/');
1998            }
1999            url.push_str(path);
2000        }
2001
2002        if let Some(query) = exchange
2003            .input
2004            .header("CamelHttpQuery")
2005            .and_then(|v| v.as_str())
2006        {
2007            url.push('?');
2008            url.push_str(query);
2009        } else if !config.query_params.is_empty() {
2010            let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); // allow-unwrap
2011            for (k, v) in &config.query_params {
2012                parsed.query_pairs_mut().append_pair(k, v);
2013            }
2014            url = parsed.to_string();
2015        }
2016
2017        url
2018    }
2019
2020    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2021        status >= range.0 && status <= range.1
2022    }
2023
2024    /// Whether the HTTP method is entity-enclosing (may carry a request
2025    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
2026    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
2027    /// §9.3.1/§9.3.2).
2028    fn is_entity_enclosing(method: &str) -> bool {
2029        matches!(method, "POST" | "PUT" | "PATCH")
2030    }
2031}
2032
2033impl Service<Exchange> for HttpProducer {
2034    type Response = Exchange;
2035    type Error = CamelError;
2036    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2037
2038    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2039        Poll::Ready(Ok(()))
2040    }
2041
2042    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
2043        let config = self.config.clone();
2044        let client = self.client.clone();
2045        let http_config = self.http_config.clone();
2046
2047        Box::pin(async move {
2048            let method_str = HttpProducer::resolve_method(&exchange, &config);
2049            // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
2050            // and PATCH may carry a request body. Any other resolved method
2051            // drops the exchange body before the request is built (Apache
2052            // Camel `HttpMethods` parity).
2053            let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
2054            let url = HttpProducer::resolve_url(&exchange, &config);
2055
2056            // SECURITY: Validate URL for SSRF
2057            ssrf::validate_url_for_ssrf(&url, &config)?;
2058
2059            // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
2060            // (L-H2). When the URL uses a domain name and SSRF protection is active,
2061            // build a per-request client with resolve_to_addrs so reqwest connects
2062            // directly to the validated addresses without re-resolving DNS.
2063            let resolved = ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2064            let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2065                build_client(&http_config, Some((host.as_str(), addrs)))
2066            } else {
2067                client
2068            };
2069
2070            debug!(
2071                correlation_id = %exchange.correlation_id(),
2072                method = %method_str,
2073                url = %url,
2074                "HTTP request"
2075            );
2076
2077            let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2078                CamelError::ProcessorError(format!("Invalid HTTP method '{}': {}", method_str, e))
2079            })?;
2080
2081            // Collect headers for potential redirect replay
2082            let mut collected_headers: Vec<(
2083                reqwest::header::HeaderName,
2084                reqwest::header::HeaderValue,
2085            )> = Vec::new();
2086
2087            if let Some(user_agent) = &config.user_agent
2088                && !config.bridge_endpoint
2089                && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2090            {
2091                collected_headers.push((reqwest::header::USER_AGENT, val));
2092            }
2093
2094            // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
2095            #[cfg(feature = "otel")]
2096            let should_inject_otel = !config.bridge_endpoint;
2097            #[cfg(feature = "otel")]
2098            if should_inject_otel {
2099                let mut otel_headers = HashMap::new();
2100                camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2101                for (k, v) in otel_headers {
2102                    if let (Ok(name), Ok(val)) = (
2103                        reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2104                        reqwest::header::HeaderValue::from_str(&v),
2105                    ) {
2106                        collected_headers.push((name, val));
2107                    }
2108                }
2109            }
2110
2111            let conn_tokens = header_policy::connection_tokens(
2112                exchange
2113                    .input
2114                    .headers
2115                    .iter()
2116                    .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2117                    .filter_map(|(_, v)| v.as_str()),
2118            );
2119
2120            for (key, value) in &exchange.input.headers {
2121                if !key.starts_with("Camel")
2122                    && !config
2123                        .skip_request_headers
2124                        .iter()
2125                        .any(|h| h.eq_ignore_ascii_case(key))
2126                    && !header_policy::excluded_outbound(key, &conn_tokens)
2127                    && let Some(val_str) = value.as_str()
2128                    && let (Ok(name), Ok(val)) = (
2129                        reqwest::header::HeaderName::from_bytes(key.as_bytes()),
2130                        reqwest::header::HeaderValue::from_str(val_str),
2131                    )
2132                {
2133                    collected_headers.push((name, val));
2134                }
2135            }
2136
2137            // Auth headers
2138            if !config.bridge_endpoint {
2139                match &config.auth {
2140                    HttpAuth::None => {}
2141                    HttpAuth::Basic { username, password } => {
2142                        use base64::Engine;
2143                        // allow-secret: credentials combined for base64 Basic auth header
2144                        let credentials = format!("{username}:{password}");
2145                        let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
2146                        if let Ok(val) =
2147                            reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
2148                        {
2149                            collected_headers.push((reqwest::header::AUTHORIZATION, val));
2150                        }
2151                    }
2152                    HttpAuth::Bearer { token } => {
2153                        // allow-secret: Bearer token in Authorization header
2154                        let bearer = format!("Bearer {token}");
2155                        if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
2156                            collected_headers.push((reqwest::header::AUTHORIZATION, val));
2157                        }
2158                    }
2159                }
2160
2161                if config.connection_close
2162                    && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
2163                {
2164                    collected_headers.push((reqwest::header::CONNECTION, val));
2165                }
2166            }
2167
2168            // Materialize body
2169            let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
2170            let materialized_body: Option<Vec<u8>> = if is_stream_body {
2171                if suppress_body {
2172                    // A stream body dropped under a non-entity-enclosing
2173                    // method always warns (its emptiness is unknowable) and
2174                    // stays consumed (mem::take). The stream attach arm below
2175                    // still runs its outer flag check, but the inner `if let
2176                    // Body::Stream` re-match fails on the now-Empty body, so
2177                    // no stream is attached and no AlreadyConsumed error can
2178                    // fire.
2179                    std::mem::take(&mut exchange.input.body);
2180                    // log-policy: handler-owned
2181                    tracing::warn!(
2182                        correlation_id = %exchange.correlation_id(),
2183                        method = %method_str,
2184                        "dropping request body for non-entity-enclosing HTTP method"
2185                    );
2186                }
2187                None // Streams can't be replayed on redirect
2188            } else {
2189                let body = std::mem::take(&mut exchange.input.body);
2190                let bytes = body.into_bytes(config.max_body_size).await?;
2191                if bytes.is_empty() {
2192                    // Empty body: nothing to send and nothing to warn about.
2193                    None
2194                } else if suppress_body {
2195                    // log-policy: handler-owned
2196                    tracing::warn!(
2197                        correlation_id = %exchange.correlation_id(),
2198                        method = %method_str,
2199                        "dropping request body for non-entity-enclosing HTTP method"
2200                    );
2201                    None
2202                } else {
2203                    Some(bytes.to_vec())
2204                }
2205            };
2206
2207            let response = if config.follow_redirects && !is_stream_body {
2208                // Use manual redirect loop with per-hop SSRF validation
2209                ssrf::send_with_ssrf_safe_redirects(
2210                    &client,
2211                    &http_config,
2212                    &config,
2213                    method,
2214                    &url,
2215                    collected_headers,
2216                    materialized_body,
2217                    config.max_redirects,
2218                    config.response_timeout,
2219                )
2220                .await?
2221            } else {
2222                // Direct send (no redirect following, or streaming body)
2223                let mut request = client.request(method, &url);
2224
2225                if let Some(timeout) = config.response_timeout {
2226                    request = request.timeout(timeout);
2227                }
2228
2229                for (name, value) in &collected_headers {
2230                    request = request.header(name, value);
2231                }
2232
2233                if is_stream_body {
2234                    if let Body::Stream(ref s) = exchange.input.body {
2235                        let mut stream_lock = s.stream.lock().await;
2236                        if let Some(stream) = stream_lock.take() {
2237                            request = request.body(reqwest::Body::wrap_stream(stream));
2238                        } else {
2239                            return Err(CamelError::AlreadyConsumed);
2240                        }
2241                    }
2242                } else if let Some(ref body_bytes) = materialized_body {
2243                    request = request.body(body_bytes.clone());
2244                }
2245
2246                request
2247                    .send()
2248                    .await
2249                    .map_err(|e| CamelError::ProcessorError(format!("HTTP request failed: {e}")))?
2250            };
2251
2252            let status_code = response.status().as_u16();
2253            let status_text = response
2254                .status()
2255                .canonical_reason()
2256                .unwrap_or("Unknown")
2257                .to_string();
2258
2259            for (key, value) in response.headers() {
2260                if config
2261                    .skip_response_headers
2262                    .iter()
2263                    .any(|h| h.eq_ignore_ascii_case(key.as_str()))
2264                {
2265                    continue;
2266                }
2267                if let Ok(val_str) = value.to_str() {
2268                    exchange.input.set_header(
2269                        title_case_header(key.as_str()),
2270                        serde_json::Value::String(val_str.to_string()),
2271                    );
2272                }
2273            }
2274
2275            exchange.input.set_header(
2276                "CamelHttpResponseCode",
2277                serde_json::Value::Number(status_code.into()),
2278            );
2279            exchange.input.set_header(
2280                "CamelHttpResponseText",
2281                serde_json::Value::String(status_text.clone()),
2282            );
2283
2284            // Read response body with timeout and size guard (HTTP-004, HTTP-005)
2285            let read_timeout = Duration::from_millis(config.read_timeout_ms);
2286            let response_body = tokio::time::timeout(read_timeout, async {
2287                // Check Content-Length header before allocating
2288                if let Some(content_len) = response.content_length()
2289                    && content_len > config.max_response_bytes as u64
2290                {
2291                    return Err(CamelError::ProcessorError(format!(
2292                        "Response body too large: {} bytes exceeds limit of {} bytes",
2293                        content_len, config.max_response_bytes
2294                    )));
2295                }
2296                // Use bytes_stream() for lazy streaming with size guard
2297                use futures::TryStreamExt;
2298                let mut stream = response.bytes_stream();
2299                let mut total: usize = 0;
2300                let mut collected = Vec::new();
2301                while let Some(chunk) = stream.try_next().await.map_err(|e| {
2302                    CamelError::ProcessorError(format!("Failed to read response body: {e}"))
2303                })? {
2304                    total += chunk.len();
2305                    if total > config.max_response_bytes {
2306                        return Err(CamelError::ProcessorError(format!(
2307                            "Response body too large: {} bytes exceeds limit of {} bytes",
2308                            total, config.max_response_bytes
2309                        )));
2310                    }
2311                    collected.push(chunk);
2312                }
2313                let mut result = bytes::BytesMut::with_capacity(total);
2314                for chunk in collected {
2315                    result.extend_from_slice(&chunk);
2316                }
2317                Ok::<bytes::Bytes, CamelError>(result.freeze())
2318            })
2319            .await
2320            .map_err(|_| {
2321                CamelError::ProcessorError(format!(
2322                    "Read timeout after {}ms",
2323                    config.read_timeout_ms
2324                ))
2325            })??;
2326
2327            if config.throw_exception_on_failure
2328                && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
2329            {
2330                return Err(CamelError::HttpOperationFailed {
2331                    method: method_str,
2332                    url,
2333                    status_code,
2334                    status_text,
2335                    response_body: Some(String::from_utf8_lossy(&response_body).to_string()),
2336                });
2337            }
2338
2339            if !response_body.is_empty() {
2340                exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
2341            }
2342
2343            debug!(
2344                correlation_id = %exchange.correlation_id(),
2345                status = status_code,
2346                url = %url,
2347                "HTTP response"
2348            );
2349            Ok(exchange)
2350        })
2351    }
2352}
2353
2354/// Serializes tests that mutate or depend on the global `ServerRegistry`.
2355///
2356/// `ServerRegistry::global()` is a process-wide singleton that persists
2357/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
2358/// with another test that has a live server on a fixed port (e.g. 9991),
2359/// the registry entry is removed while the OS socket is still bound, so
2360/// the next `get_or_spawn` call on that port fails with "Address already
2361/// in use". Holding this mutex for the full body of each affected test
2362/// prevents the race without requiring `--test-threads=1`.
2363#[cfg(test)]
2364pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2365
2366/// Map a pipeline error to an HTTP reply.
2367///
2368/// Extracted from the inline `match` in `dispatch_handler` for unit
2369/// testability (rc-1dk4). `TypeConversionFailed` (e.g. malformed JSON
2370/// body) maps to `400 Bad Request` with a structured JSON error body;
2371/// `Unauthenticated`/`Unauthorized` keep their existing `401`/`403`
2372/// mappings; all other errors map to `500 Internal Server Error`.
2373fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
2374    match e {
2375        CamelError::Unauthenticated(msg) => {
2376            tracing::warn!(error = %msg, path = %path, "Authentication failed");
2377            HttpReply {
2378                status: 401,
2379                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
2380                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
2381            }
2382        }
2383        CamelError::Unauthorized(msg) => {
2384            tracing::warn!(error = %msg, path = %path, "Authorization failed");
2385            HttpReply {
2386                status: 403,
2387                headers: vec![],
2388                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
2389            }
2390        }
2391        CamelError::TypeConversionFailed(msg) => {
2392            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
2393            let body = serde_json::to_string(&serde_json::json!({
2394                "error": "bad_request",
2395                "message": msg,
2396            }))
2397            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
2398            HttpReply {
2399                status: 400,
2400                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2401                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2402            }
2403        }
2404        CamelError::ValidationError(msg) => {
2405            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
2406            let body = serde_json::to_string(&serde_json::json!({
2407                "error": "validation_error",
2408                "message": msg,
2409            }))
2410            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
2411            HttpReply {
2412                status: 400,
2413                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2414                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2415            }
2416        }
2417        CamelError::ConsumerStopping => {
2418            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
2419            HttpReply {
2420                status: 503,
2421                headers: vec![],
2422                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2423            }
2424        }
2425        e => {
2426            // log-policy: handler-owned
2427            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
2428            HttpReply {
2429                status: 500,
2430                headers: vec![],
2431                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
2432            }
2433        }
2434    }
2435}
2436
2437/// Select the HTTP response headers emitted by the consumer reply finaliser
2438/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
2439/// `dispatch_handler` for unit testability.
2440///
2441/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
2442/// server-owned headers, plus `content-length`/`content-type` (re-derived),
2443/// and any header named by a `Connection` token. Appends a single
2444/// `Content-Type` from `user_content_type` falling back to
2445/// `inferred_content_type` when either is present.
2446fn select_response_headers(
2447    headers: &HashMap<String, serde_json::Value>,
2448    user_content_type: Option<String>,
2449    inferred_content_type: Option<String>,
2450) -> Vec<(String, String)> {
2451    let conn_tokens = header_policy::connection_tokens(
2452        headers
2453            .iter()
2454            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2455            .filter_map(|(_, v)| v.as_str()),
2456    );
2457    let mut selected: Vec<(String, String)> = headers
2458        .iter()
2459        .filter(|(k, _)| !k.starts_with("Camel"))
2460        .filter(|(k, _)| !header_policy::excluded_response(k, &conn_tokens))
2461        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2462        .collect();
2463    if let Some(ct) = user_content_type.or(inferred_content_type) {
2464        selected.push(("Content-Type".to_string(), ct));
2465    }
2466    selected
2467}
2468
2469#[cfg(test)]
2470mod tests {
2471    use camel_component_api::test_support::{NoopRuntimeObservability, PanicRuntimeObservability};
2472    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2473        std::sync::Arc::new(PanicRuntimeObservability)
2474    }
2475    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2476        std::sync::Arc::new(PanicRuntimeObservability)
2477    }
2478    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2479        std::sync::Arc::new(NoopRuntimeObservability)
2480    }
2481
2482    use super::*;
2483    use crate::rest_match::PathSegment;
2484    use camel_component_api::{Message, NoOpComponentContext};
2485    use std::sync::Arc;
2486    use std::time::Duration;
2487
2488    fn test_producer_ctx() -> ProducerContext {
2489        ProducerContext::new()
2490    }
2491
2492    #[test]
2493    fn test_http_config_defaults() {
2494        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
2495        assert_eq!(config.base_url, "http://localhost:8080/api");
2496        assert!(config.http_method.is_none());
2497        assert!(config.throw_exception_on_failure);
2498        assert_eq!(config.ok_status_code_range, (200, 299));
2499        assert!(config.response_timeout.is_none());
2500        assert!(matches!(config.auth, HttpAuth::None));
2501        assert!(!config.bridge_endpoint);
2502        assert!(!config.connection_close);
2503    }
2504
2505    #[test]
2506    fn test_http_config_scheme() {
2507        // UriConfig trait method returns "http" as primary scheme
2508        assert_eq!(HttpEndpointConfig::scheme(), "http");
2509    }
2510
2511    #[test]
2512    fn test_http_config_from_components() {
2513        // Test from_components directly (trait method)
2514        let components = camel_component_api::UriComponents {
2515            scheme: "https".to_string(),
2516            path: "//api.example.com/v1".to_string(),
2517            params: std::collections::HashMap::from([(
2518                "httpMethod".to_string(),
2519                "POST".to_string(),
2520            )]),
2521        };
2522        let config = HttpEndpointConfig::from_components(components).unwrap();
2523        assert_eq!(config.base_url, "https://api.example.com/v1");
2524        assert_eq!(config.http_method, Some("POST".to_string()));
2525    }
2526
2527    #[test]
2528    fn test_http_config_with_options() {
2529        let config = HttpEndpointConfig::from_uri(
2530            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
2531        ).unwrap();
2532        assert_eq!(config.base_url, "https://api.example.com/v1");
2533        assert_eq!(config.http_method, Some("PUT".to_string()));
2534        assert!(!config.throw_exception_on_failure);
2535        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
2536    }
2537
2538    #[test]
2539    fn test_http_endpoint_config_auth_and_headers_options() {
2540        let config = HttpEndpointConfig::from_uri(
2541            "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
2542        )
2543        .unwrap();
2544
2545        assert!(matches!(
2546            config.auth,
2547            HttpAuth::Basic { username, password } if username == "u" && password == "p"
2548        ));
2549        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
2550        assert!(config.bridge_endpoint);
2551        assert!(config.connection_close);
2552        assert_eq!(
2553            config.skip_request_headers,
2554            vec!["authorization".to_string(), "x-secret".to_string()]
2555        );
2556        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
2557    }
2558
2559    #[test]
2560    fn test_http_endpoint_config_bearer_auth() {
2561        let config = HttpEndpointConfig::from_uri(
2562            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
2563        )
2564        .unwrap();
2565        assert!(matches!(
2566            config.auth,
2567            HttpAuth::Bearer { token } if token == "t"
2568        ));
2569    }
2570
2571    #[test]
2572    fn rejects_cookie_handling_inmemory() {
2573        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
2574        match result {
2575            Err(CamelError::InvalidUri(msg)) => {
2576                assert!(
2577                    msg.contains("cookieHandling is not supported"),
2578                    "expected rejection message, got: {msg}"
2579                );
2580            }
2581            other => panic!("expected InvalidUri error, got: {other:?}"),
2582        }
2583    }
2584
2585    #[test]
2586    fn rejects_cookie_handling_disabled() {
2587        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
2588        match result {
2589            Err(CamelError::InvalidUri(msg)) => {
2590                assert!(
2591                    msg.contains("cookieHandling is not supported"),
2592                    "expected rejection message, got: {msg}"
2593                );
2594            }
2595            other => panic!("expected InvalidUri error, got: {other:?}"),
2596        }
2597    }
2598
2599    #[test]
2600    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
2601        let config = HttpConfig::default()
2602            .with_response_timeout_ms(999)
2603            .with_allow_internal(true)
2604            .with_blocked_hosts(vec!["evil.com".to_string()])
2605            .with_max_body_size(12345);
2606        let endpoint =
2607            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
2608        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
2609        assert!(endpoint.allow_internal);
2610        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
2611        assert_eq!(endpoint.max_body_size, 12345);
2612    }
2613
2614    #[test]
2615    fn test_from_uri_with_defaults_uri_overrides_config() {
2616        let config = HttpConfig::default()
2617            .with_response_timeout_ms(999)
2618            .with_allow_internal(true)
2619            .with_blocked_hosts(vec!["evil.com".to_string()])
2620            .with_max_body_size(12345);
2621        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
2622            "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
2623            &config,
2624        )
2625        .unwrap();
2626        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
2627        assert!(!endpoint.allow_internal);
2628        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
2629        assert_eq!(endpoint.max_body_size, 99);
2630    }
2631
2632    #[test]
2633    fn test_http_config_ok_status_range() {
2634        let config =
2635            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
2636        assert_eq!(config.ok_status_code_range, (200, 204));
2637    }
2638
2639    #[test]
2640    fn test_http_config_wrong_scheme() {
2641        let result = HttpEndpointConfig::from_uri("file:/tmp");
2642        assert!(result.is_err());
2643    }
2644
2645    #[test]
2646    fn test_http_component_scheme() {
2647        let component = HttpComponent::new();
2648        assert_eq!(component.scheme(), "http");
2649    }
2650
2651    #[test]
2652    fn test_https_component_scheme() {
2653        let component = HttpsComponent::new();
2654        assert_eq!(component.scheme(), "https");
2655    }
2656
2657    #[test]
2658    fn test_http_endpoint_creates_consumer() {
2659        let component = HttpComponent::new();
2660        let ctx = NoOpComponentContext;
2661        let endpoint = component
2662            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
2663            .unwrap();
2664        assert!(endpoint.create_consumer(rt()).is_ok());
2665    }
2666
2667    #[test]
2668    fn test_https_endpoint_creates_consumer_errors_without_tls() {
2669        let component = HttpsComponent::new();
2670        let ctx = NoOpComponentContext;
2671        let endpoint = component
2672            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
2673            .unwrap();
2674        // https:// without tlsCert/tlsKey must fail (scheme enforcement)
2675        assert!(endpoint.create_consumer(rt()).is_err());
2676    }
2677
2678    #[test]
2679    fn test_http_endpoint_creates_producer() {
2680        let ctx = test_producer_ctx();
2681        let component = HttpComponent::new();
2682        let endpoint_ctx = NoOpComponentContext;
2683        let endpoint = component
2684            .create_endpoint("http://localhost/api", &endpoint_ctx)
2685            .unwrap();
2686        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
2687    }
2688
2689    // -----------------------------------------------------------------------
2690    // Producer tests
2691    // -----------------------------------------------------------------------
2692
2693    #[tokio::test]
2694    async fn test_producer_with_token_provider() {
2695        use camel_auth::oauth2::TokenProvider;
2696        use tower::ServiceExt;
2697
2698        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
2699            Arc::new(std::sync::Mutex::new(None));
2700        let captured_clone = Arc::clone(&captured_auth);
2701
2702        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2703        let port = listener.local_addr().unwrap().port();
2704
2705        let _handle = tokio::spawn(async move {
2706            use tokio::io::{AsyncReadExt, AsyncWriteExt};
2707            if let Ok((mut stream, _)) = listener.accept().await {
2708                let mut buf = vec![0u8; 8192];
2709                let n = stream.read(&mut buf).await.unwrap_or(0);
2710                let request = String::from_utf8_lossy(&buf[..n]).to_string();
2711                let auth = request
2712                    .lines()
2713                    .find(|l| l.to_lowercase().starts_with("authorization:"))
2714                    .map(|l| {
2715                        l.split(':')
2716                            .nth(1)
2717                            .map(|s| s.trim().to_string())
2718                            .unwrap_or_default()
2719                    });
2720                *captured_clone.lock().unwrap() = auth;
2721                let body = r#"{"echo":"ok"}"#;
2722                let resp = format!(
2723                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2724                    body.len(),
2725                    body
2726                );
2727                let _ = stream.write_all(resp.as_bytes()).await;
2728            }
2729        });
2730
2731        #[derive(Debug)]
2732        struct StaticProvider;
2733        #[async_trait::async_trait]
2734        impl TokenProvider for StaticProvider {
2735            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
2736                Ok("injected-token".into())
2737            }
2738        }
2739
2740        let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
2741        let ctx = test_producer_ctx();
2742        let component = HttpComponent::new();
2743        let endpoint_ctx = NoOpComponentContext;
2744        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
2745        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2746
2747        let exchange = Exchange::new(Message::new("hello"));
2748
2749        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
2750        let mut layered = layer.layer(producer);
2751        let result = layered.ready().await.unwrap().call(exchange).await;
2752        assert!(result.is_ok(), "producer call failed: {:?}", result);
2753
2754        tokio::time::sleep(Duration::from_millis(100)).await;
2755        let auth = captured_auth.lock().unwrap().take();
2756        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
2757    }
2758
2759    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
2760        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2761        let addr = listener.local_addr().unwrap();
2762        let url = format!("http://127.0.0.1:{}", addr.port());
2763
2764        let handle = tokio::spawn(async move {
2765            loop {
2766                if let Ok((mut stream, _)) = listener.accept().await {
2767                    tokio::spawn(async move {
2768                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2769                        let mut buf = vec![0u8; 4096];
2770                        let n = stream.read(&mut buf).await.unwrap_or(0);
2771                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
2772
2773                        let method = request.split_whitespace().next().unwrap_or("GET");
2774
2775                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
2776                        let response = format!(
2777                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
2778                            body.len(),
2779                            body
2780                        );
2781                        let _ = stream.write_all(response.as_bytes()).await;
2782                    });
2783                }
2784            }
2785        });
2786
2787        (url, handle)
2788    }
2789
2790    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
2791        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2792        let addr = listener.local_addr().unwrap();
2793        let url = format!("http://127.0.0.1:{}", addr.port());
2794
2795        let handle = tokio::spawn(async move {
2796            loop {
2797                if let Ok((mut stream, _)) = listener.accept().await {
2798                    let status = status;
2799                    tokio::spawn(async move {
2800                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2801                        let mut buf = vec![0u8; 4096];
2802                        let _ = stream.read(&mut buf).await;
2803
2804                        let status_text = match status {
2805                            404 => "Not Found",
2806                            500 => "Internal Server Error",
2807                            _ => "Error",
2808                        };
2809                        let body = "error body";
2810                        let response = format!(
2811                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
2812                            status,
2813                            status_text,
2814                            body.len(),
2815                            body
2816                        );
2817                        let _ = stream.write_all(response.as_bytes()).await;
2818                    });
2819                }
2820            }
2821        });
2822
2823        (url, handle)
2824    }
2825
2826    async fn start_request_capturing_server() -> (
2827        String,
2828        Arc<std::sync::Mutex<Option<String>>>,
2829        tokio::task::JoinHandle<()>,
2830    ) {
2831        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2832        let port = listener.local_addr().unwrap().port();
2833        let url = format!("http://127.0.0.1:{port}");
2834        let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
2835        let captured_clone = Arc::clone(&captured);
2836        let handle = tokio::spawn(async move {
2837            use tokio::io::{AsyncReadExt, AsyncWriteExt};
2838            if let Ok((mut stream, _)) = listener.accept().await {
2839                let mut buf = vec![0u8; 16384];
2840                let n = stream.read(&mut buf).await.unwrap_or(0);
2841                let request = String::from_utf8_lossy(&buf[..n]).to_string();
2842                if request.contains("\r\n\r\n") {
2843                    *captured_clone.lock().unwrap() = Some(request);
2844                }
2845                let body = r#"{"echo":"ok"}"#;
2846                let resp = format!(
2847                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2848                    body.len(),
2849                    body
2850                );
2851                let _ = stream.write_all(resp.as_bytes()).await;
2852            }
2853        });
2854        (url, captured, handle)
2855    }
2856
2857    #[tokio::test]
2858    async fn test_http_producer_get_request() {
2859        use tower::ServiceExt;
2860
2861        let (url, _handle) = start_test_server().await;
2862        let ctx = test_producer_ctx();
2863
2864        let component = HttpComponent::new();
2865        let endpoint_ctx = NoOpComponentContext;
2866        let endpoint = component
2867            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
2868            .unwrap();
2869        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2870
2871        let exchange = Exchange::new(Message::default());
2872        let result = producer.oneshot(exchange).await.unwrap();
2873
2874        let status = result
2875            .input
2876            .header("CamelHttpResponseCode")
2877            .and_then(|v| v.as_u64())
2878            .unwrap();
2879        assert_eq!(status, 200);
2880
2881        assert!(!result.input.body.is_empty());
2882    }
2883
2884    #[tokio::test]
2885    async fn producer_excludes_host_and_framing() {
2886        use tower::ServiceExt;
2887
2888        let (url, captured, _handle) = start_request_capturing_server().await;
2889        let ctx = test_producer_ctx();
2890        let component = HttpComponent::new();
2891        let endpoint_ctx = NoOpComponentContext;
2892        let endpoint = component
2893            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
2894            .unwrap();
2895        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2896
2897        let mut exchange = Exchange::new(Message::default());
2898        exchange.input.set_header("Host", "localhost");
2899        exchange.input.set_header("Content-Length", "42");
2900        exchange.input.set_header("Connection", "keep-alive");
2901        exchange.input.set_header("Upgrade", "h2c");
2902
2903        let result = producer.oneshot(exchange).await;
2904        assert!(result.is_ok(), "producer call failed: {:?}", result);
2905
2906        tokio::time::sleep(Duration::from_millis(100)).await;
2907        let request = captured
2908            .lock()
2909            .unwrap()
2910            .take()
2911            .expect("no outbound request captured");
2912        let lower = request.to_ascii_lowercase();
2913        assert!(
2914            !lower.contains("\r\nhost: localhost"),
2915            "forwarded Host: localhost must be stripped\n{request}"
2916        );
2917        assert!(
2918            !lower.contains("content-length: 42"),
2919            "exchange Content-Length must not be copied\n{request}"
2920        );
2921        assert!(
2922            !lower.lines().any(|l| l.starts_with("connection:")),
2923            "Connection header must not be forwarded\n{request}"
2924        );
2925        assert!(
2926            !lower.lines().any(|l| l.starts_with("upgrade:")),
2927            "Upgrade header must not be forwarded\n{request}"
2928        );
2929        let host_header = lower
2930            .lines()
2931            .find(|l| l.starts_with("host:"))
2932            .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
2933            .expect("outbound Host header must be set by reqwest");
2934        assert!(
2935            host_header.starts_with("127.0.0.1:"),
2936            "outbound Host '{host_header}' must match the capture-server address"
2937        );
2938    }
2939
2940    #[tokio::test]
2941    async fn producer_forwards_request_only_headers() {
2942        use tower::ServiceExt;
2943
2944        let (url, captured, _handle) = start_request_capturing_server().await;
2945        let ctx = test_producer_ctx();
2946        let component = HttpComponent::new();
2947        let endpoint_ctx = NoOpComponentContext;
2948        let endpoint = component
2949            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
2950            .unwrap();
2951        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2952
2953        let mut exchange = Exchange::new(Message::default());
2954        exchange.input.set_header("Accept", "application/json");
2955        exchange.input.set_header("User-Agent", "myclient/1.0");
2956
2957        let result = producer.oneshot(exchange).await;
2958        assert!(result.is_ok(), "producer call failed: {:?}", result);
2959
2960        tokio::time::sleep(Duration::from_millis(100)).await;
2961        let request = captured
2962            .lock()
2963            .unwrap()
2964            .take()
2965            .expect("no outbound request captured");
2966        let lower = request.to_ascii_lowercase();
2967        assert!(
2968            lower.contains("accept: application/json"),
2969            "request-only Accept header must be forwarded\n{request}"
2970        );
2971        assert!(
2972            lower.contains("user-agent: myclient/1.0"),
2973            "request-only User-Agent header must be forwarded\n{request}"
2974        );
2975    }
2976
2977    #[tokio::test]
2978    async fn producer_honours_skip_request_headers() {
2979        use tower::ServiceExt;
2980
2981        let (url, captured, _handle) = start_request_capturing_server().await;
2982        let ctx = test_producer_ctx();
2983        let component = HttpComponent::new();
2984        let endpoint_ctx = NoOpComponentContext;
2985        let endpoint = component
2986            .create_endpoint(
2987                &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
2988                &endpoint_ctx,
2989            )
2990            .unwrap();
2991        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2992
2993        let mut exchange = Exchange::new(Message::default());
2994        exchange.input.set_header("Authorization", "Bearer x");
2995
2996        let result = producer.oneshot(exchange).await;
2997        assert!(result.is_ok(), "producer call failed: {:?}", result);
2998
2999        tokio::time::sleep(Duration::from_millis(100)).await;
3000        let request = captured
3001            .lock()
3002            .unwrap()
3003            .take()
3004            .expect("no outbound request captured");
3005        assert!(
3006            !request.to_ascii_lowercase().contains("authorization"),
3007            "Authorization must be stripped by skipRequestHeaders\n{request}"
3008        );
3009    }
3010
3011    #[tokio::test]
3012    async fn test_http_producer_post_with_body() {
3013        use tower::ServiceExt;
3014
3015        let (url, _handle) = start_test_server().await;
3016        let ctx = test_producer_ctx();
3017
3018        let component = HttpComponent::new();
3019        let endpoint_ctx = NoOpComponentContext;
3020        let endpoint = component
3021            .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
3022            .unwrap();
3023        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3024
3025        let exchange = Exchange::new(Message::new("request body"));
3026        let result = producer.oneshot(exchange).await.unwrap();
3027
3028        let status = result
3029            .input
3030            .header("CamelHttpResponseCode")
3031            .and_then(|v| v.as_u64())
3032            .unwrap();
3033        assert_eq!(status, 200);
3034    }
3035
3036    #[tokio::test]
3037    async fn test_http_producer_method_from_header() {
3038        use tower::ServiceExt;
3039
3040        let (url, _handle) = start_test_server().await;
3041        let ctx = test_producer_ctx();
3042
3043        let component = HttpComponent::new();
3044        let endpoint_ctx = NoOpComponentContext;
3045        let endpoint = component
3046            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3047            .unwrap();
3048        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3049
3050        let mut exchange = Exchange::new(Message::default());
3051        exchange.input.set_header(
3052            "CamelHttpMethod",
3053            serde_json::Value::String("DELETE".to_string()),
3054        );
3055
3056        let result = producer.oneshot(exchange).await.unwrap();
3057        let status = result
3058            .input
3059            .header("CamelHttpResponseCode")
3060            .and_then(|v| v.as_u64())
3061            .unwrap();
3062        assert_eq!(status, 200);
3063    }
3064
3065    #[tokio::test]
3066    async fn test_http_producer_forced_method() {
3067        use tower::ServiceExt;
3068
3069        let (url, _handle) = start_test_server().await;
3070        let ctx = test_producer_ctx();
3071
3072        let component = HttpComponent::new();
3073        let endpoint_ctx = NoOpComponentContext;
3074        let endpoint = component
3075            .create_endpoint(
3076                &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
3077                &endpoint_ctx,
3078            )
3079            .unwrap();
3080        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3081
3082        let exchange = Exchange::new(Message::default());
3083        let result = producer.oneshot(exchange).await.unwrap();
3084
3085        let status = result
3086            .input
3087            .header("CamelHttpResponseCode")
3088            .and_then(|v| v.as_u64())
3089            .unwrap();
3090        assert_eq!(status, 200);
3091    }
3092
3093    #[tokio::test]
3094    async fn test_http_producer_throw_exception_on_failure() {
3095        use tower::ServiceExt;
3096
3097        let (url, _handle) = start_status_server(404).await;
3098        let ctx = test_producer_ctx();
3099
3100        let component = HttpComponent::new();
3101        let endpoint_ctx = NoOpComponentContext;
3102        let endpoint = component
3103            .create_endpoint(
3104                &format!("{url}/not-found?allowInternal=true"),
3105                &endpoint_ctx,
3106            )
3107            .unwrap();
3108        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3109
3110        let exchange = Exchange::new(Message::default());
3111        let result = producer.oneshot(exchange).await;
3112        assert!(result.is_err());
3113
3114        match result.unwrap_err() {
3115            CamelError::HttpOperationFailed { status_code, .. } => {
3116                assert_eq!(status_code, 404);
3117            }
3118            e => panic!("Expected HttpOperationFailed, got: {e}"),
3119        }
3120    }
3121
3122    #[tokio::test]
3123    async fn test_http_producer_no_throw_on_failure() {
3124        use tower::ServiceExt;
3125
3126        let (url, _handle) = start_status_server(500).await;
3127        let ctx = test_producer_ctx();
3128
3129        let component = HttpComponent::new();
3130        let endpoint_ctx = NoOpComponentContext;
3131        let endpoint = component
3132            .create_endpoint(
3133                &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
3134                &endpoint_ctx,
3135            )
3136            .unwrap();
3137        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3138
3139        let exchange = Exchange::new(Message::default());
3140        let result = producer.oneshot(exchange).await.unwrap();
3141
3142        let status = result
3143            .input
3144            .header("CamelHttpResponseCode")
3145            .and_then(|v| v.as_u64())
3146            .unwrap();
3147        assert_eq!(status, 500);
3148    }
3149
3150    #[tokio::test]
3151    async fn test_http_producer_uri_override() {
3152        use tower::ServiceExt;
3153
3154        let (url, _handle) = start_test_server().await;
3155        let ctx = test_producer_ctx();
3156
3157        let component = HttpComponent::new();
3158        let endpoint_ctx = NoOpComponentContext;
3159        let endpoint = component
3160            .create_endpoint(
3161                "http://localhost:1/does-not-exist?allowInternal=true",
3162                &endpoint_ctx,
3163            )
3164            .unwrap();
3165        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3166
3167        let mut exchange = Exchange::new(Message::default());
3168        exchange.input.set_header(
3169            "CamelHttpUri",
3170            serde_json::Value::String(format!("{url}/api")),
3171        );
3172
3173        let result = producer.oneshot(exchange).await.unwrap();
3174        let status = result
3175            .input
3176            .header("CamelHttpResponseCode")
3177            .and_then(|v| v.as_u64())
3178            .unwrap();
3179        assert_eq!(status, 200);
3180    }
3181
3182    #[tokio::test]
3183    async fn test_http_producer_response_headers_mapped() {
3184        use tower::ServiceExt;
3185
3186        let (url, _handle) = start_test_server().await;
3187        let ctx = test_producer_ctx();
3188
3189        let component = HttpComponent::new();
3190        let endpoint_ctx = NoOpComponentContext;
3191        let endpoint = component
3192            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3193            .unwrap();
3194        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3195
3196        let exchange = Exchange::new(Message::default());
3197        let result = producer.oneshot(exchange).await.unwrap();
3198
3199        assert!(
3200            result.input.header("Content-Type").is_some(),
3201            "Response should have Content-Type header"
3202        );
3203        assert!(result.input.header("CamelHttpResponseText").is_some());
3204    }
3205
3206    // -----------------------------------------------------------------------
3207    // Bug fix tests: Client configuration per-endpoint
3208    // -----------------------------------------------------------------------
3209
3210    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
3211        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3212        let addr = listener.local_addr().unwrap();
3213        let url = format!("http://127.0.0.1:{}", addr.port());
3214
3215        let handle = tokio::spawn(async move {
3216            use tokio::io::{AsyncReadExt, AsyncWriteExt};
3217            loop {
3218                if let Ok((mut stream, _)) = listener.accept().await {
3219                    tokio::spawn(async move {
3220                        let mut buf = vec![0u8; 4096];
3221                        let n = stream.read(&mut buf).await.unwrap_or(0);
3222                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
3223
3224                        // Check if this is a request to /final
3225                        if request.contains("GET /final") {
3226                            let body = r#"{"status":"final"}"#;
3227                            let response = format!(
3228                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3229                                body.len(),
3230                                body
3231                            );
3232                            let _ = stream.write_all(response.as_bytes()).await;
3233                        } else {
3234                            // Redirect to /final
3235                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
3236                            let _ = stream.write_all(response.as_bytes()).await;
3237                        }
3238                    });
3239                }
3240            }
3241        });
3242
3243        (url, handle)
3244    }
3245
3246    struct CapturedRequest {
3247        method: String,
3248        path: String,
3249        body: Vec<u8>,
3250        content_length: Option<String>,
3251        transfer_encoding: Option<String>,
3252    }
3253
3254    /// Parse a request head plus its Content-Length-driven body from a freshly
3255    /// accepted connection. Returns `None` if the client closes before sending
3256    /// a complete request head. Does NOT read until EOF/shutdown (reqwest pools
3257    /// keep-alive connections and never sends FIN) and does NOT rely on a
3258    /// single fixed-size read (a segmented small body would flake).
3259    async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
3260        use tokio::io::AsyncReadExt;
3261
3262        // Read the request head (up to and including the terminating CRLF CRLF).
3263        let mut buf: Vec<u8> = Vec::new();
3264        let mut chunk = [0u8; 4096];
3265        let head_end: usize;
3266        loop {
3267            let n = stream.read(&mut chunk).await.unwrap_or(0);
3268            if n == 0 {
3269                return None;
3270            }
3271            buf.extend_from_slice(&chunk[..n]);
3272            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
3273                head_end = pos + 4;
3274                break;
3275            }
3276        }
3277
3278        // Parse the request head.
3279        let head = String::from_utf8_lossy(&buf[..head_end]);
3280        let mut lines = head.split("\r\n");
3281        let request_line = lines.next().unwrap_or("");
3282        let mut parts = request_line.split_whitespace();
3283        let method = parts.next().unwrap_or("").to_string();
3284        let path = parts.next().unwrap_or("").to_string();
3285
3286        let mut content_length: Option<String> = None;
3287        let mut transfer_encoding: Option<String> = None;
3288        for line in lines {
3289            if let Some((name, value)) = line.split_once(':') {
3290                let name = name.trim().to_ascii_lowercase();
3291                let value = value.trim().to_string();
3292                if name == "content-length" {
3293                    content_length = Some(value);
3294                } else if name == "transfer-encoding" {
3295                    transfer_encoding = Some(value);
3296                }
3297            }
3298        }
3299
3300        // Content-Length-driven exact read. A missing header means a 0-length body.
3301        let body_len: usize = content_length
3302            .as_deref()
3303            .and_then(|v| v.parse::<usize>().ok())
3304            .unwrap_or(0);
3305
3306        let mut body: Vec<u8> = buf[head_end..].to_vec();
3307        while body.len() < body_len {
3308            let n = stream.read(&mut chunk).await.unwrap_or(0);
3309            if n == 0 {
3310                break;
3311            }
3312            body.extend_from_slice(&chunk[..n]);
3313        }
3314        body.truncate(body_len);
3315
3316        Some(CapturedRequest {
3317            method,
3318            path,
3319            body,
3320            content_length,
3321            transfer_encoding,
3322        })
3323    }
3324
3325    /// A raw-TCP capture server. Each connection parses the request head, then
3326    /// performs a Content-Length-driven exact read of the body (see
3327    /// [`capture_request`]). Each connection is dropped after the response so
3328    /// every hop opens a fresh connection.
3329    async fn start_capture_server() -> (
3330        String,
3331        tokio::task::JoinHandle<()>,
3332        Arc<Mutex<Vec<CapturedRequest>>>,
3333    ) {
3334        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3335        let addr = listener.local_addr().unwrap();
3336        let url = format!("http://127.0.0.1:{}", addr.port());
3337
3338        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3339        let captured_for_return = Arc::clone(&captured);
3340
3341        let handle = tokio::spawn(async move {
3342            use tokio::io::AsyncWriteExt;
3343            loop {
3344                if let Ok((mut stream, _)) = listener.accept().await {
3345                    let captured = Arc::clone(&captured);
3346                    tokio::spawn(async move {
3347                        let Some(req) = capture_request(&mut stream).await else {
3348                            return;
3349                        };
3350                        captured.lock().unwrap().push(req);
3351
3352                        // 200 OK with Content-Length: 0 and no body, then drop
3353                        // the stream so the client opens a fresh connection.
3354                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
3355                        let _ = stream.write_all(response.as_bytes()).await;
3356                    });
3357                }
3358            }
3359        });
3360
3361        (url, handle, captured_for_return)
3362    }
3363
3364    /// A raw-TCP capture server whose `/hop307` and `/hop308` paths answer with
3365    /// `307 Temporary Redirect` / `308 Permanent Redirect` to `/final`, and
3366    /// whose `/final` path answers `200 OK` with an empty body. Every hop
3367    /// records a `CapturedRequest` (Content-Length-driven exact read) and drops
3368    /// the connection after responding so each hop is a fresh connection.
3369    async fn start_redirect_capture_server() -> (
3370        String,
3371        tokio::task::JoinHandle<()>,
3372        Arc<Mutex<Vec<CapturedRequest>>>,
3373    ) {
3374        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3375        let addr = listener.local_addr().unwrap();
3376        let url = format!("http://127.0.0.1:{}", addr.port());
3377
3378        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3379        let captured_for_return = Arc::clone(&captured);
3380
3381        let handle = tokio::spawn(async move {
3382            use tokio::io::AsyncWriteExt;
3383            loop {
3384                if let Ok((mut stream, _)) = listener.accept().await {
3385                    let captured = Arc::clone(&captured);
3386                    tokio::spawn(async move {
3387                        let Some(req) = capture_request(&mut stream).await else {
3388                            return;
3389                        };
3390                        let path = req.path.clone();
3391                        captured.lock().unwrap().push(req);
3392
3393                        let (status_line, location) = match path.as_str() {
3394                            "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
3395                            "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
3396                            "/final" => ("HTTP/1.1 200 OK", None),
3397                            _ => ("HTTP/1.1 404 Not Found", None),
3398                        };
3399
3400                        let response = match location {
3401                            Some(loc) => format!(
3402                                "{status_line}\r\nLocation: {loc}\r\nContent-Length: 0\r\n\r\n"
3403                            ),
3404                            None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
3405                        };
3406                        let _ = stream.write_all(response.as_bytes()).await;
3407                    });
3408                }
3409            }
3410        });
3411
3412        (url, handle, captured_for_return)
3413    }
3414
3415    #[tokio::test]
3416    async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
3417        use tower::ServiceExt;
3418
3419        let (url, _handle, captured) = start_capture_server().await;
3420        let ctx = test_producer_ctx();
3421
3422        let component = HttpComponent::with_config(HttpConfig::default());
3423        let endpoint_ctx = NoOpComponentContext;
3424        let endpoint = component
3425            .create_endpoint(
3426                &format!("{url}?httpMethod=GET&allowInternal=true"),
3427                &endpoint_ctx,
3428            )
3429            .unwrap();
3430        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3431
3432        let mut exchange = Exchange::new(Message::default());
3433        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3434
3435        let result = producer.oneshot(exchange).await.unwrap();
3436
3437        let status = result
3438            .input
3439            .header("CamelHttpResponseCode")
3440            .and_then(|v| v.as_u64())
3441            .unwrap();
3442        assert_eq!(status, 200);
3443
3444        let captured = captured.lock().unwrap();
3445        assert_eq!(captured.len(), 1, "expected exactly one captured request");
3446        let req = &captured[0];
3447        assert_eq!(req.method, "GET");
3448        // `httpMethod`/`allowInternal` are URI options, not request-target
3449        // query params, so the origin-form target is just "/".
3450        assert_eq!(req.path, "/");
3451        assert!(req.body.is_empty(), "GET must not carry a body");
3452        assert!(
3453            req.content_length.is_none(),
3454            "suppressed request must not carry Content-Length"
3455        );
3456        assert!(
3457            req.transfer_encoding.is_none(),
3458            "suppressed request must not carry Transfer-Encoding"
3459        );
3460
3461        // The exchange body is consumed by the producer (std::mem::take).
3462        assert!(
3463            result.input.body.is_empty(),
3464            "exchange body must be consumed"
3465        );
3466    }
3467
3468    #[tokio::test]
3469    async fn test_head_with_body_suppressed_via_header() {
3470        use tower::ServiceExt;
3471
3472        let (url, _handle, captured) = start_capture_server().await;
3473        let ctx = test_producer_ctx();
3474
3475        let component = HttpComponent::with_config(HttpConfig::default());
3476        let endpoint_ctx = NoOpComponentContext;
3477        let endpoint = component
3478            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3479            .unwrap();
3480        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3481
3482        let mut exchange = Exchange::new(Message::default());
3483        exchange.input.set_header(
3484            "CamelHttpMethod",
3485            serde_json::Value::String("HEAD".to_string()),
3486        );
3487        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3488
3489        let result = producer.oneshot(exchange).await.unwrap();
3490        let status = result
3491            .input
3492            .header("CamelHttpResponseCode")
3493            .and_then(|v| v.as_u64())
3494            .unwrap();
3495        assert_eq!(status, 200);
3496
3497        let captured = captured.lock().unwrap();
3498        assert_eq!(captured.len(), 1);
3499        let req = &captured[0];
3500        assert_eq!(req.method, "HEAD");
3501        assert!(req.body.is_empty(), "HEAD must not carry a body");
3502    }
3503
3504    #[tokio::test]
3505    async fn test_delete_options_trace_with_body_suppressed() {
3506        use tower::ServiceExt;
3507
3508        let (url, _handle, captured) = start_capture_server().await;
3509        let ctx = test_producer_ctx();
3510        let component = HttpComponent::with_config(HttpConfig::default());
3511        let endpoint_ctx = NoOpComponentContext;
3512
3513        for method in ["DELETE", "OPTIONS", "TRACE"] {
3514            let endpoint = component
3515                .create_endpoint(
3516                    &format!("{url}?httpMethod={method}&allowInternal=true"),
3517                    &endpoint_ctx,
3518                )
3519                .unwrap();
3520            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3521
3522            let mut exchange = Exchange::new(Message::default());
3523            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3524
3525            let result = producer.oneshot(exchange).await.unwrap();
3526            let status = result
3527                .input
3528                .header("CamelHttpResponseCode")
3529                .and_then(|v| v.as_u64())
3530                .unwrap();
3531            assert_eq!(status, 200, "method {method} should succeed");
3532        }
3533
3534        let captured = captured.lock().unwrap();
3535        assert_eq!(captured.len(), 3, "expected three captured requests");
3536        for method in ["DELETE", "OPTIONS", "TRACE"] {
3537            let req = captured
3538                .iter()
3539                .find(|r| r.method == method)
3540                .unwrap_or_else(|| panic!("missing captured request for {method}"));
3541            assert!(req.body.is_empty(), "{} must not carry a body", req.method);
3542        }
3543    }
3544
3545    #[tokio::test]
3546    async fn test_post_put_patch_with_body_still_sent() {
3547        use tower::ServiceExt;
3548
3549        let (url, _handle, captured) = start_capture_server().await;
3550        let ctx = test_producer_ctx();
3551        let component = HttpComponent::with_config(HttpConfig::default());
3552        let endpoint_ctx = NoOpComponentContext;
3553
3554        for method in ["POST", "PUT", "PATCH"] {
3555            let endpoint = component
3556                .create_endpoint(
3557                    &format!("{url}?httpMethod={method}&allowInternal=true"),
3558                    &endpoint_ctx,
3559                )
3560                .unwrap();
3561            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3562
3563            let payload = format!("body-for-{method}");
3564            let mut exchange = Exchange::new(Message::default());
3565            exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
3566
3567            let result = producer.oneshot(exchange).await.unwrap();
3568            let status = result
3569                .input
3570                .header("CamelHttpResponseCode")
3571                .and_then(|v| v.as_u64())
3572                .unwrap();
3573            assert_eq!(status, 200, "method {method} should succeed");
3574        }
3575
3576        let captured = captured.lock().unwrap();
3577        assert_eq!(captured.len(), 3, "expected three captured requests");
3578        for method in ["POST", "PUT", "PATCH"] {
3579            let req = captured
3580                .iter()
3581                .find(|r| r.method == method)
3582                .unwrap_or_else(|| panic!("missing captured request for {method}"));
3583            let expected = format!("body-for-{method}");
3584            assert!(!req.body.is_empty(), "{method} must still carry its body");
3585            assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
3586        }
3587    }
3588
3589    /// A GET with a stream body must not attach the stream: the entity-enclosing
3590    /// gate drops the stream (mem::take) before the request is built, leaving
3591    /// the exchange body Empty instead of a partially-consumed Body::Stream.
3592    #[tokio::test]
3593    async fn test_stream_body_under_get_not_attached() {
3594        use tower::ServiceExt;
3595
3596        let (url, _handle, captured) = start_capture_server().await;
3597        let ctx = test_producer_ctx();
3598
3599        let component = HttpComponent::with_config(HttpConfig::default());
3600        let endpoint_ctx = NoOpComponentContext;
3601        let endpoint = component
3602            .create_endpoint(
3603                &format!("{url}?httpMethod=GET&allowInternal=true"),
3604                &endpoint_ctx,
3605            )
3606            .unwrap();
3607        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3608
3609        let chunks: Vec<Result<bytes::Bytes, CamelError>> =
3610            vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
3611        let stream = Box::pin(futures::stream::iter(chunks));
3612        let mut exchange = Exchange::new(Message::default());
3613        exchange.input.body = Body::Stream(StreamBody {
3614            stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
3615            metadata: StreamMetadata::default(),
3616        });
3617
3618        let result = producer.oneshot(exchange).await.unwrap();
3619
3620        let status = result
3621            .input
3622            .header("CamelHttpResponseCode")
3623            .and_then(|v| v.as_u64())
3624            .unwrap();
3625        assert_eq!(status, 200);
3626
3627        let captured = captured.lock().unwrap();
3628        assert_eq!(captured.len(), 1, "expected exactly one captured request");
3629        assert!(
3630            captured[0].body.is_empty(),
3631            "GET must not carry a stream body"
3632        );
3633        assert!(
3634            captured[0].transfer_encoding.is_none(),
3635            "suppressed request must not carry Transfer-Encoding"
3636        );
3637        assert!(
3638            captured[0].content_length.is_none(),
3639            "suppressed request must not carry Content-Length"
3640        );
3641        assert!(
3642            result.input.body.is_empty(),
3643            "exchange body must be consumed to Empty, not left as a stream"
3644        );
3645    }
3646
3647    /// A suppressed body must never be replayed across 307/308 redirect hops:
3648    /// the gate empties `materialized_body` before the redirect loop runs, so
3649    /// neither the first hop nor the final hop carries the body.
3650    #[tokio::test]
3651    async fn test_redirect_hops_never_replay_suppressed_body() {
3652        use tower::ServiceExt;
3653
3654        let (url, _handle, captured) = start_redirect_capture_server().await;
3655        let ctx = test_producer_ctx();
3656
3657        let component =
3658            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3659        let endpoint_ctx = NoOpComponentContext;
3660
3661        for path in ["/hop307", "/hop308"] {
3662            let endpoint = component
3663                .create_endpoint(
3664                    &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
3665                    &endpoint_ctx,
3666                )
3667                .unwrap();
3668            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3669
3670            let mut exchange = Exchange::new(Message::default());
3671            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3672
3673            let result = producer.oneshot(exchange).await.unwrap();
3674            let status = result
3675                .input
3676                .header("CamelHttpResponseCode")
3677                .and_then(|v| v.as_u64())
3678                .unwrap();
3679            assert_eq!(
3680                status, 200,
3681                "redirect chain for {path} should end at /final"
3682            );
3683        }
3684
3685        // Two chains (307 and 308), each with two hops (redirect + final).
3686        let captured = captured.lock().unwrap();
3687        assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
3688        for req in captured.iter() {
3689            assert!(
3690                req.body.is_empty(),
3691                "hop {} {} must not carry a body",
3692                req.method,
3693                req.path
3694            );
3695        }
3696    }
3697
3698    /// The warn! emitted on a suppressed body renders three distinguishable
3699    /// substrings in the log line (tracing-subscriber default field format):
3700    ///   - the message:       "dropping request body ..."
3701    ///   - `method = %method_str`            → `method=GET`
3702    ///   - `correlation_id = %exchange.correlation_id()` → `correlation_id=<uuid>`
3703    /// The closure matches all three so exactly one warn per suppressed
3704    /// request is required (the "HTTP request" debug! also carries
3705    /// `method=GET` and the same `correlation_id=`, but not the message).
3706    #[tracing_test::traced_test]
3707    #[tokio::test]
3708    async fn test_suppressed_body_logs_exactly_one_warn() {
3709        use tower::ServiceExt;
3710
3711        let (url, _handle, _captured) = start_capture_server().await;
3712        let ctx = test_producer_ctx();
3713
3714        let component = HttpComponent::with_config(HttpConfig::default());
3715        let endpoint_ctx = NoOpComponentContext;
3716        let endpoint = component
3717            .create_endpoint(
3718                &format!("{url}?httpMethod=GET&allowInternal=true"),
3719                &endpoint_ctx,
3720            )
3721            .unwrap();
3722        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3723
3724        let mut exchange = Exchange::new(Message::default());
3725        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3726        let correlation_id = exchange.correlation_id().to_string();
3727
3728        let result = producer.oneshot(exchange).await.unwrap();
3729        let status = result
3730            .input
3731            .header("CamelHttpResponseCode")
3732            .and_then(|v| v.as_u64())
3733            .unwrap();
3734        assert_eq!(status, 200);
3735
3736        logs_assert(|lines: &[&str]| {
3737            let hits = lines
3738                .iter()
3739                .filter(|l| {
3740                    l.contains("dropping request body")
3741                        && l.contains("method=GET")
3742                        && l.contains(&format!("correlation_id={correlation_id}"))
3743                })
3744                .count();
3745            match hits {
3746                1 => Ok(()),
3747                n => Err(format!("expected exactly one body-drop warn, found {n}")),
3748            }
3749        });
3750    }
3751
3752    #[tracing_test::traced_test]
3753    #[tokio::test]
3754    async fn test_empty_body_get_emits_no_warn() {
3755        use tower::ServiceExt;
3756
3757        let (url, _handle, _captured) = start_capture_server().await;
3758        let ctx = test_producer_ctx();
3759
3760        let component = HttpComponent::with_config(HttpConfig::default());
3761        let endpoint_ctx = NoOpComponentContext;
3762        let endpoint = component
3763            .create_endpoint(
3764                &format!("{url}?httpMethod=GET&allowInternal=true"),
3765                &endpoint_ctx,
3766            )
3767            .unwrap();
3768        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3769
3770        let exchange = Exchange::new(Message::default());
3771        let result = producer.oneshot(exchange).await.unwrap();
3772        let status = result
3773            .input
3774            .header("CamelHttpResponseCode")
3775            .and_then(|v| v.as_u64())
3776            .unwrap();
3777        assert_eq!(status, 200);
3778
3779        logs_assert(|lines: &[&str]| {
3780            let hits = lines
3781                .iter()
3782                .filter(|l| l.contains("dropping request body"))
3783                .count();
3784            match hits {
3785                0 => Ok(()),
3786                n => Err(format!("expected no body-drop warn, found {n}")),
3787            }
3788        });
3789    }
3790
3791    #[tokio::test]
3792    async fn test_follow_redirects_false_does_not_follow() {
3793        use tower::ServiceExt;
3794
3795        let (url, _handle) = start_redirect_server().await;
3796        let ctx = test_producer_ctx();
3797
3798        let component =
3799            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
3800        let endpoint_ctx = NoOpComponentContext;
3801        let endpoint = component
3802            .create_endpoint(
3803                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
3804                &endpoint_ctx,
3805            )
3806            .unwrap();
3807        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3808
3809        let exchange = Exchange::new(Message::default());
3810        let result = producer.oneshot(exchange).await.unwrap();
3811
3812        // Should get 302, NOT follow redirect to 200
3813        let status = result
3814            .input
3815            .header("CamelHttpResponseCode")
3816            .and_then(|v| v.as_u64())
3817            .unwrap();
3818        assert_eq!(
3819            status, 302,
3820            "Should NOT follow redirect when followRedirects=false"
3821        );
3822    }
3823
3824    #[tokio::test]
3825    async fn test_follow_redirects_true_follows_redirect() {
3826        use tower::ServiceExt;
3827
3828        let (url, _handle) = start_redirect_server().await;
3829        let ctx = test_producer_ctx();
3830
3831        let component =
3832            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3833        let endpoint_ctx = NoOpComponentContext;
3834        let endpoint = component
3835            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3836            .unwrap();
3837        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3838
3839        let exchange = Exchange::new(Message::default());
3840        let result = producer.oneshot(exchange).await.unwrap();
3841
3842        // Should follow redirect and get 200
3843        let status = result
3844            .input
3845            .header("CamelHttpResponseCode")
3846            .and_then(|v| v.as_u64())
3847            .unwrap();
3848        assert_eq!(
3849            status, 200,
3850            "Should follow redirect when followRedirects=true"
3851        );
3852    }
3853
3854    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
3855    /// This verifies the manual redirect loop executes correctly.
3856    #[tokio::test]
3857    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
3858        use tower::ServiceExt;
3859
3860        // Use the existing redirect server which redirects to /final on the same server
3861        let (url, _handle) = start_redirect_server().await;
3862        let ctx = test_producer_ctx();
3863
3864        let component =
3865            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3866        let endpoint_ctx = NoOpComponentContext;
3867        let endpoint = component
3868            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3869            .unwrap();
3870        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3871
3872        let exchange = Exchange::new(Message::default());
3873        let result = producer.oneshot(exchange).await;
3874
3875        // With allowInternal=true, the redirect should succeed
3876        assert!(
3877            result.is_ok(),
3878            "Redirect should succeed with allowInternal=true, got: {:?}",
3879            result
3880        );
3881        let exchange = result.unwrap();
3882        let status = exchange
3883            .input
3884            .header("CamelHttpResponseCode")
3885            .and_then(|v| v.as_u64())
3886            .unwrap();
3887        assert_eq!(status, 200, "Should follow redirect to /final");
3888    }
3889
3890    /// With allowInternal=true, redirects to private IPs should be followed.
3891    #[tokio::test]
3892    async fn test_redirect_to_private_ip_allowed_when_configured() {
3893        use tower::ServiceExt;
3894
3895        // Start a server that redirects to /final on the same server (127.0.0.1)
3896        let (url, _handle) = start_redirect_server().await;
3897        let ctx = test_producer_ctx();
3898
3899        let component =
3900            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3901        let endpoint_ctx = NoOpComponentContext;
3902        let endpoint = component
3903            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3904            .unwrap();
3905        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3906
3907        let exchange = Exchange::new(Message::default());
3908        let result = producer.oneshot(exchange).await.unwrap();
3909
3910        let status = result
3911            .input
3912            .header("CamelHttpResponseCode")
3913            .and_then(|v| v.as_u64())
3914            .unwrap();
3915        assert_eq!(
3916            status, 200,
3917            "Should follow redirect to private IP when allowInternal=true"
3918        );
3919    }
3920
3921    /// Integration test: with allowInternal=false (default), a redirect to a
3922    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
3923    #[tokio::test]
3924    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
3925        use tower::ServiceExt;
3926
3927        // Server that redirects to the AWS metadata endpoint (link-local private IP)
3928        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3929        let addr = listener.local_addr().unwrap();
3930        let url = format!("http://127.0.0.1:{}", addr.port());
3931
3932        let handle = tokio::spawn(async move {
3933            use tokio::io::{AsyncReadExt, AsyncWriteExt};
3934            loop {
3935                if let Ok((mut stream, _)) = listener.accept().await {
3936                    tokio::spawn(async move {
3937                        let mut buf = vec![0u8; 4096];
3938                        let _ = stream.read(&mut buf).await;
3939                        // Always redirect to the metadata endpoint
3940                        let response = "HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data/\r\nContent-Length: 0\r\n\r\n";
3941                        let _ = stream.write_all(response.as_bytes()).await;
3942                    });
3943                }
3944            }
3945        });
3946
3947        let ctx = test_producer_ctx();
3948        let component =
3949            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3950        let endpoint_ctx = NoOpComponentContext;
3951        // allowInternal=false is the default — do NOT set it
3952        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
3953        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3954
3955        let exchange = Exchange::new(Message::default());
3956        let result = producer.oneshot(exchange).await;
3957
3958        // Must be an error — SSRF guard blocks the redirect target
3959        assert!(
3960            result.is_err(),
3961            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
3962        );
3963        let err = result.unwrap_err().to_string();
3964        assert!(
3965            err.contains("blocked IP")
3966                || err.contains("private IP")
3967                || err.contains("SSRF")
3968                || err.contains("not allowed"),
3969            "Error should mention SSRF/IP blocking, got: {err}"
3970        );
3971
3972        handle.abort();
3973    }
3974
3975    /// Integration test: exceeding maxRedirects produces a clear error.
3976    #[tokio::test]
3977    async fn test_too_many_redirects_returns_error() {
3978        use tower::ServiceExt;
3979
3980        // Server that always redirects to itself (infinite loop)
3981        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3982        let addr = listener.local_addr().unwrap();
3983        let url = format!("http://127.0.0.1:{}", addr.port());
3984
3985        let handle = tokio::spawn(async move {
3986            use tokio::io::{AsyncReadExt, AsyncWriteExt};
3987            loop {
3988                if let Ok((mut stream, _)) = listener.accept().await {
3989                    tokio::spawn(async move {
3990                        let mut buf = vec![0u8; 4096];
3991                        let _ = stream.read(&mut buf).await;
3992                        // Always redirect to /loop
3993                        let response =
3994                            "HTTP/1.1 302 Found\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n";
3995                        let _ = stream.write_all(response.as_bytes()).await;
3996                    });
3997                }
3998            }
3999        });
4000
4001        let ctx = test_producer_ctx();
4002        let component =
4003            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4004        let endpoint_ctx = NoOpComponentContext;
4005        let endpoint = component
4006            .create_endpoint(
4007                &format!("{url}?allowInternal=true&maxRedirects=2"),
4008                &endpoint_ctx,
4009            )
4010            .unwrap();
4011        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4012
4013        let exchange = Exchange::new(Message::default());
4014        let result = producer.oneshot(exchange).await;
4015
4016        // With the fix, exceeding max redirects returns the redirect response
4017        // as-is instead of erroring. The 302 redirect response is returned
4018        // after followRedirects exhausts the allowed redirect count (2).
4019        // Disable throwExceptionOnFailure to inspect the raw response status.
4020        //
4021        // Old behavior: Err("Too many redirects (max 2)")
4022        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
4023        match result {
4024            Err(e) => {
4025                // If throw_exception_on_failure is on, we get HttpOperationFailed
4026                let msg = e.to_string();
4027                assert!(
4028                    msg.contains("HTTP operation failed") || msg.contains("302"),
4029                    "expected redirect-after-exhaustion error, got: {msg}"
4030                );
4031            }
4032            Ok(ex) => {
4033                let response_code = ex
4034                    .input
4035                    .header("CamelHttpResponseCode")
4036                    .and_then(|v| v.as_u64());
4037                assert_eq!(
4038                    response_code,
4039                    Some(302),
4040                    "expected 302 after exhausting redirects"
4041                );
4042            }
4043        }
4044
4045        handle.abort();
4046    }
4047
4048    #[tokio::test]
4049    async fn test_query_params_forwarded_to_http_request() {
4050        use tower::ServiceExt;
4051
4052        let (url, _handle) = start_test_server().await;
4053        let ctx = test_producer_ctx();
4054
4055        let component = HttpComponent::new();
4056        let endpoint_ctx = NoOpComponentContext;
4057        // apiKey is NOT a Camel option, should be forwarded as query param
4058        let endpoint = component
4059            .create_endpoint(
4060                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
4061                &endpoint_ctx,
4062            )
4063            .unwrap();
4064        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4065
4066        let exchange = Exchange::new(Message::default());
4067        let result = producer.oneshot(exchange).await.unwrap();
4068
4069        // The test server returns the request info in response
4070        // We just verify it succeeds (the query param was sent)
4071        let status = result
4072            .input
4073            .header("CamelHttpResponseCode")
4074            .and_then(|v| v.as_u64())
4075            .unwrap();
4076        assert_eq!(status, 200);
4077    }
4078
4079    #[tokio::test]
4080    async fn test_non_camel_query_params_are_forwarded() {
4081        // This test verifies Bug #3 fix: non-Camel options should be forwarded
4082        // We'll test the config parsing, not the actual HTTP call
4083        let config = HttpEndpointConfig::from_uri(
4084            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
4085        )
4086        .unwrap();
4087
4088        // apiKey and token are NOT Camel options, should be forwarded
4089        assert!(
4090            config.query_params.contains_key("apiKey"),
4091            "apiKey should be preserved"
4092        );
4093        assert!(
4094            config.query_params.contains_key("token"),
4095            "token should be preserved"
4096        );
4097        assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
4098        assert_eq!(config.query_params.get("token").unwrap(), "abc456");
4099
4100        // httpMethod IS a Camel option, should NOT be in query_params
4101        assert!(
4102            !config.query_params.contains_key("httpMethod"),
4103            "httpMethod should not be forwarded"
4104        );
4105    }
4106
4107    #[test]
4108    fn test_query_params_are_url_encoded_when_resolving_url() {
4109        let config =
4110            HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
4111        let exchange = Exchange::new(Message::default());
4112
4113        let url = HttpProducer::resolve_url(&exchange, &config);
4114
4115        assert!(url.contains("q=hello+world"), "url was: {url}");
4116        assert!(url.contains("tag=a%2Bb"), "url was: {url}");
4117    }
4118
4119    // -----------------------------------------------------------------------
4120    // Timeout tests (HTTP-004)
4121    // -----------------------------------------------------------------------
4122
4123    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
4124        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4125        let addr = listener.local_addr().unwrap();
4126        let url = format!("http://127.0.0.1:{}", addr.port());
4127
4128        let handle = tokio::spawn(async move {
4129            loop {
4130                if let Ok((mut stream, _)) = listener.accept().await {
4131                    let delay = delay_ms;
4132                    tokio::spawn(async move {
4133                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4134                        let mut buf = vec![0u8; 4096];
4135                        let _ = stream.read(&mut buf).await;
4136                        // Send headers immediately (no Content-Length → chunked)
4137                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
4138                        let _ = stream.write_all(headers.as_bytes()).await;
4139                        // Delay before sending body chunk
4140                        tokio::time::sleep(Duration::from_millis(delay)).await;
4141                        let body = r#"{"status":"slow"}"#;
4142                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
4143                        let _ = stream.write_all(chunk.as_bytes()).await;
4144                    });
4145                }
4146            }
4147        });
4148
4149        (url, handle)
4150    }
4151
4152    #[tokio::test]
4153    async fn test_http_producer_timeout() {
4154        use tower::ServiceExt;
4155
4156        // Server delays 500ms, client timeout is 100ms → should timeout
4157        let (url, _handle) = start_slow_server(500).await;
4158        let ctx = test_producer_ctx();
4159
4160        let component = HttpComponent::with_config(
4161            HttpConfig::default()
4162                .with_read_timeout_ms(100)
4163                .with_response_timeout_ms(30_000), // generous response timeout
4164        );
4165        let endpoint_ctx = NoOpComponentContext;
4166        let endpoint = component
4167            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
4168            .unwrap();
4169        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4170
4171        let exchange = Exchange::new(Message::default());
4172        let result = producer.oneshot(exchange).await;
4173
4174        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
4175        let err = result.unwrap_err().to_string();
4176        assert!(
4177            err.contains("Read timeout") || err.contains("timeout"),
4178            "Error should mention timeout, got: {}",
4179            err
4180        );
4181    }
4182
4183    #[tokio::test]
4184    async fn test_http_producer_no_timeout_when_fast() {
4185        use tower::ServiceExt;
4186
4187        let (url, _handle) = start_test_server().await;
4188        let ctx = test_producer_ctx();
4189
4190        let component =
4191            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
4192        let endpoint_ctx = NoOpComponentContext;
4193        let endpoint = component
4194            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4195            .unwrap();
4196        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4197
4198        let exchange = Exchange::new(Message::default());
4199        let result = producer.oneshot(exchange).await.unwrap();
4200
4201        let status = result
4202            .input
4203            .header("CamelHttpResponseCode")
4204            .and_then(|v| v.as_u64())
4205            .unwrap();
4206        assert_eq!(status, 200);
4207    }
4208
4209    // -----------------------------------------------------------------------
4210    // SSRF Protection tests
4211    // -----------------------------------------------------------------------
4212
4213    #[tokio::test]
4214    async fn test_http_producer_blocks_metadata_endpoint() {
4215        use tower::ServiceExt;
4216
4217        let ctx = test_producer_ctx();
4218        let component = HttpComponent::new();
4219        let endpoint_ctx = NoOpComponentContext;
4220        let endpoint = component
4221            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
4222            .unwrap();
4223        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4224
4225        let mut exchange = Exchange::new(Message::default());
4226        exchange.input.set_header(
4227            "CamelHttpUri",
4228            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
4229        );
4230
4231        let result = producer.oneshot(exchange).await;
4232        assert!(result.is_err(), "Should block AWS metadata endpoint");
4233
4234        let err = result.unwrap_err();
4235        assert!(
4236            err.to_string().contains("Private IP"),
4237            "Error should mention private IP blocking, got: {}",
4238            err
4239        );
4240    }
4241
4242    #[test]
4243    fn test_ssrf_config_defaults() {
4244        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
4245        assert!(
4246            !config.allow_internal,
4247            "Private IPs should be blocked by default"
4248        );
4249        assert!(
4250            config.blocked_hosts.is_empty(),
4251            "Blocked hosts should be empty by default"
4252        );
4253    }
4254
4255    #[test]
4256    fn test_ssrf_config_allow_internal() {
4257        let config =
4258            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
4259        assert!(
4260            config.allow_internal,
4261            "Private IPs should be allowed when explicitly set"
4262        );
4263    }
4264
4265    #[test]
4266    fn test_ssrf_config_blocked_hosts() {
4267        let config = HttpEndpointConfig::from_uri(
4268            "http://example.com/api?blockedHosts=evil.com,malware.net",
4269        )
4270        .unwrap();
4271        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
4272    }
4273
4274    #[tokio::test]
4275    async fn test_http_producer_blocks_localhost() {
4276        use tower::ServiceExt;
4277
4278        let ctx = test_producer_ctx();
4279        let component = HttpComponent::new();
4280        let endpoint_ctx = NoOpComponentContext;
4281        let endpoint = component
4282            .create_endpoint("http://example.com/api", &endpoint_ctx)
4283            .unwrap();
4284        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4285
4286        let mut exchange = Exchange::new(Message::default());
4287        exchange.input.set_header(
4288            "CamelHttpUri",
4289            serde_json::Value::String("http://localhost:8080/internal".to_string()),
4290        );
4291
4292        let result = producer.oneshot(exchange).await;
4293        assert!(result.is_err(), "Should block localhost");
4294    }
4295
4296    #[tokio::test]
4297    async fn test_http_producer_blocks_loopback_ip() {
4298        use tower::ServiceExt;
4299
4300        let ctx = test_producer_ctx();
4301        let component = HttpComponent::new();
4302        let endpoint_ctx = NoOpComponentContext;
4303        let endpoint = component
4304            .create_endpoint("http://example.com/api", &endpoint_ctx)
4305            .unwrap();
4306        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4307
4308        let mut exchange = Exchange::new(Message::default());
4309        exchange.input.set_header(
4310            "CamelHttpUri",
4311            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
4312        );
4313
4314        let result = producer.oneshot(exchange).await;
4315        assert!(result.is_err(), "Should block loopback IP");
4316    }
4317
4318    #[tokio::test]
4319    async fn test_http_producer_allows_private_ip_when_enabled() {
4320        use tower::ServiceExt;
4321
4322        let ctx = test_producer_ctx();
4323        let component = HttpComponent::new();
4324        let endpoint_ctx = NoOpComponentContext;
4325        // With allowInternal=true, the validation should pass
4326        // (actual connection will fail, but that's expected)
4327        let endpoint = component
4328            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
4329            .unwrap();
4330        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4331
4332        let exchange = Exchange::new(Message::default());
4333
4334        // The request will fail because we can't connect, but it should NOT fail
4335        // due to SSRF protection
4336        let result = producer.oneshot(exchange).await;
4337        // We expect connection error, not SSRF error
4338        if let Err(ref e) = result {
4339            let err_str = e.to_string();
4340            assert!(
4341                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
4342                "Should not be SSRF error, got: {}",
4343                err_str
4344            );
4345        }
4346    }
4347
4348    // -----------------------------------------------------------------------
4349    // HttpServerConfig tests
4350    // -----------------------------------------------------------------------
4351
4352    #[test]
4353    fn test_http_server_config_parse() {
4354        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
4355        assert_eq!(cfg.host, "0.0.0.0");
4356        assert_eq!(cfg.port, 8080);
4357        assert_eq!(cfg.path, "/orders");
4358        assert_eq!(cfg.max_inflight_requests, 1024);
4359    }
4360
4361    #[test]
4362    fn test_http_server_config_scheme() {
4363        // UriConfig trait method returns "http" as primary scheme
4364        assert_eq!(HttpServerConfig::scheme(), "http");
4365    }
4366
4367    #[test]
4368    fn test_http_server_config_from_components() {
4369        // Test from_components directly (trait method)
4370        let components = camel_component_api::UriComponents {
4371            scheme: "https".to_string(),
4372            path: "//0.0.0.0:8443/api".to_string(),
4373            params: std::collections::HashMap::from([
4374                ("maxRequestBody".to_string(), "5242880".to_string()),
4375                ("maxInflightRequests".to_string(), "7".to_string()),
4376            ]),
4377        };
4378        let cfg = HttpServerConfig::from_components(components).unwrap();
4379        assert_eq!(cfg.host, "0.0.0.0");
4380        assert_eq!(cfg.port, 8443);
4381        assert_eq!(cfg.path, "/api");
4382        assert_eq!(cfg.max_request_body, 5242880);
4383        assert_eq!(cfg.max_inflight_requests, 7);
4384    }
4385
4386    #[test]
4387    fn test_http_server_config_default_path() {
4388        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
4389        assert_eq!(cfg.path, "/");
4390    }
4391
4392    #[test]
4393    fn test_http_server_config_wrong_scheme() {
4394        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
4395    }
4396
4397    #[test]
4398    fn test_http_server_config_invalid_port() {
4399        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
4400    }
4401
4402    #[test]
4403    fn test_http_server_config_default_port_by_scheme() {
4404        // HTTP without explicit port should default to 80
4405        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
4406        assert_eq!(cfg_http.port, 80);
4407
4408        // HTTPS without explicit port should default to 443
4409        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
4410        assert_eq!(cfg_https.port, 443);
4411    }
4412
4413    #[test]
4414    fn test_request_envelope_and_reply_are_send() {
4415        fn assert_send<T: Send>() {}
4416        assert_send::<RequestEnvelope>();
4417        assert_send::<HttpReply>();
4418    }
4419
4420    // -----------------------------------------------------------------------
4421    // ServerRegistry tests
4422    // -----------------------------------------------------------------------
4423
4424    #[test]
4425    fn test_server_registry_global_is_singleton() {
4426        let r1 = ServerRegistry::global();
4427        let r2 = ServerRegistry::global();
4428        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
4429    }
4430
4431    #[allow(clippy::await_holding_lock)]
4432    #[tokio::test]
4433    async fn test_concurrent_get_or_spawn_returns_same_registry() {
4434        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4435        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4436        let port = listener.local_addr().unwrap().port();
4437        drop(listener);
4438
4439        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
4440            Arc::new(std::sync::Mutex::new(Vec::new()));
4441
4442        let mut handles = Vec::new();
4443        for _ in 0..4 {
4444            let results = results.clone();
4445            handles.push(tokio::spawn(async move {
4446                let registry = ServerRegistry::global()
4447                    .get_or_spawn(
4448                        "127.0.0.1",
4449                        port,
4450                        2 * 1024 * 1024,
4451                        10 * 1024 * 1024,
4452                        1024,
4453                        test_rt(),
4454                        "test-route".into(),
4455                        None,
4456                    )
4457                    .await
4458                    .unwrap();
4459                results.lock().unwrap().push(registry);
4460            }));
4461        }
4462
4463        for h in handles {
4464            h.await.unwrap();
4465        }
4466
4467        let registries = results.lock().unwrap();
4468        assert_eq!(registries.len(), 4);
4469        for i in 1..registries.len() {
4470            assert!(
4471                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
4472                "all concurrent callers should get same route registry"
4473            );
4474        }
4475    }
4476
4477    #[test]
4478    fn test_server_registry_distinguishes_host_and_port() {
4479        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4480        let rt = tokio::runtime::Runtime::new().expect("runtime");
4481        rt.block_on(async {
4482            let registry = ServerRegistry::global();
4483            // Use two distinct host values with same configured port key.
4484            // Port 0 is acceptable here because the registry key uses the configured
4485            // tuple, not the OS-assigned ephemeral port.
4486            let d1 = registry
4487                .get_or_spawn(
4488                    "127.0.0.1",
4489                    0,
4490                    1024 * 1024,
4491                    10 * 1024 * 1024,
4492                    1024,
4493                    test_rt(),
4494                    "test-route-1".into(),
4495                    None,
4496                )
4497                .await;
4498            let d2 = registry
4499                .get_or_spawn(
4500                    "0.0.0.0",
4501                    0,
4502                    1024 * 1024,
4503                    10 * 1024 * 1024,
4504                    1024,
4505                    test_rt(),
4506                    "test-route-2".into(),
4507                    None,
4508                )
4509                .await;
4510            assert!(d1.is_ok());
4511            assert!(d2.is_ok());
4512            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
4513        });
4514    }
4515
4516    #[allow(clippy::await_holding_lock)]
4517    #[tokio::test]
4518    async fn test_shared_server_max_request_body_policy_is_deterministic() {
4519        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4520        let registry = ServerRegistry::global();
4521        // First registration: maxRequestBody = 1 MB
4522        let d1 = registry
4523            .get_or_spawn(
4524                "127.0.0.1",
4525                9991,
4526                1024 * 1024,
4527                10 * 1024 * 1024,
4528                1024,
4529                test_rt(),
4530                "test-route".into(),
4531                None,
4532            )
4533            .await;
4534        assert!(d1.is_ok());
4535
4536        // Second registration on same (host,port): maxRequestBody = 2 MB
4537        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
4538        let d2 = registry
4539            .get_or_spawn(
4540                "127.0.0.1",
4541                9991,
4542                2 * 1024 * 1024,
4543                10 * 1024 * 1024,
4544                1024,
4545                test_rt(),
4546                "test-route-2".into(),
4547                None,
4548            )
4549            .await;
4550        assert!(d2.is_err());
4551        let err = d2.unwrap_err();
4552        assert!(
4553            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
4554            "Expected incompatible maxRequestBody error, got: {}",
4555            err
4556        );
4557    }
4558
4559    #[test]
4560    fn test_server_registry_reset_clears_entries() {
4561        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4562        let rt = tokio::runtime::Runtime::new().expect("runtime");
4563        rt.block_on(async {
4564            // Register something on a unique port
4565            let d1 = ServerRegistry::global()
4566                .get_or_spawn(
4567                    "127.0.0.1",
4568                    9992,
4569                    1024 * 1024,
4570                    10 * 1024 * 1024,
4571                    1024,
4572                    test_rt(),
4573                    "test-route".into(),
4574                    None,
4575                )
4576                .await;
4577            assert!(d1.is_ok());
4578
4579            // Verify entry exists
4580            let guard = ServerRegistry::global().inner.lock().expect("lock");
4581            assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
4582            drop(guard);
4583
4584            // Reset
4585            ServerRegistry::reset();
4586
4587            // Verify cleared
4588            let guard = ServerRegistry::global().inner.lock().expect("lock");
4589            assert!(
4590                guard.is_empty(),
4591                "registry should be empty after reset, has {} entries",
4592                guard.len()
4593            );
4594        });
4595    }
4596
4597    #[tokio::test]
4598    async fn registry_rejects_tls_on_plain_port() {
4599        ServerRegistry::reset();
4600        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
4601
4602        // First route: plain HTTP
4603        let _r1 = ServerRegistry::global()
4604            .get_or_spawn(
4605                "127.0.0.1",
4606                0,
4607                1024,
4608                1024,
4609                16,
4610                Arc::clone(&rt),
4611                "route-1".into(),
4612                None, // plain
4613            )
4614            .await;
4615
4616        // Second route: TLS on same port → must fail
4617        let result = ServerRegistry::global()
4618            .get_or_spawn(
4619                "127.0.0.1",
4620                0,
4621                1024,
4622                1024,
4623                16,
4624                Arc::clone(&rt),
4625                "route-2".into(),
4626                Some(crate::config::ServerTlsConfig {
4627                    cert_path: "/x.pem".into(),
4628                    key_path: "/y.pem".into(),
4629                }),
4630            )
4631            .await;
4632        assert!(result.is_err(), "must reject TLS on plain port");
4633    }
4634
4635    // -----------------------------------------------------------------------
4636    // D-L10: HTTP monitor_axum_task refcounted shutdown
4637    // -----------------------------------------------------------------------
4638
4639    #[allow(clippy::await_holding_lock)]
4640    #[tokio::test]
4641    async fn test_unregister_last_http_route_keeps_server_alive() {
4642        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4643        ServerRegistry::reset();
4644        let registry = ServerRegistry::global();
4645
4646        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4647        let port = listener.local_addr().unwrap().port();
4648        drop(listener); // Release — ServerRegistry will rebind
4649        let rt = test_rt();
4650
4651        // Register 2 routes on the same (host, port) — OnceCell returns the
4652        // same ServerHandle.
4653        let _r1 = registry
4654            .get_or_spawn(
4655                "127.0.0.1",
4656                port,
4657                1024 * 1024,
4658                10 * 1024 * 1024,
4659                16,
4660                rt.clone(),
4661                "test-route-1".into(),
4662                None,
4663            )
4664            .await
4665            .unwrap();
4666        let _r2 = registry
4667            .get_or_spawn(
4668                "127.0.0.1",
4669                port,
4670                1024 * 1024,
4671                10 * 1024 * 1024,
4672                16,
4673                rt,
4674                "test-route-2".into(),
4675                None,
4676            )
4677            .await
4678            .unwrap();
4679
4680        let key = ("127.0.0.1".to_string(), port);
4681        let cell = {
4682            let guard = registry.inner.lock().expect("lock");
4683            guard.get(&key).expect("entry should exist").clone()
4684        };
4685
4686        // Unregister first route -> monitor still alive (count = 1).
4687        registry.unregister("127.0.0.1", port).await;
4688        {
4689            let handle = cell
4690                .get()
4691                .expect("handle should still exist after first unregister");
4692            assert!(
4693                !handle.monitor_task.is_finished(),
4694                "monitor task should still be alive after first unregister"
4695            );
4696        }
4697
4698        // Unregister second route -> server stays alive (process-lifetime).
4699        registry.unregister("127.0.0.1", port).await;
4700        tokio::time::sleep(Duration::from_millis(20)).await;
4701        {
4702            let handle = cell
4703                .get()
4704                .expect("handle should still exist after last unregister");
4705            assert!(
4706                !handle.monitor_task.is_finished(),
4707                "monitor task should still be alive — server is process-lifetime"
4708            );
4709        }
4710
4711        // Entry stays in registry for potential restart.
4712        {
4713            let guard = registry.inner.lock().expect("lock");
4714            assert!(
4715                guard.get(&key).is_some(),
4716                "entry should remain in registry — server kept alive for restart"
4717            );
4718        }
4719    }
4720
4721    // -----------------------------------------------------------------------
4722    // Axum dispatch handler tests
4723    // -----------------------------------------------------------------------
4724
4725    #[tokio::test]
4726    async fn test_dispatch_handler_returns_404_for_unknown_path() {
4727        let registry = HttpRouteRegistry::new();
4728        // Nothing registered in route registry
4729        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4730        let port = listener.local_addr().unwrap().port();
4731        tokio::spawn(run_axum_server(
4732            listener,
4733            registry,
4734            2 * 1024 * 1024,
4735            10 * 1024 * 1024,
4736            Arc::new(tokio::sync::Semaphore::new(1024)),
4737            test_rt(),
4738            "test-route".into(),
4739        ));
4740
4741        // Wait for server to start
4742        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4743
4744        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
4745            .await
4746            .unwrap();
4747        assert_eq!(resp.status().as_u16(), 404);
4748    }
4749
4750    // -----------------------------------------------------------------------
4751    // HttpConsumer tests
4752    // -----------------------------------------------------------------------
4753
4754    #[tokio::test]
4755    async fn test_http_consumer_start_registers_path() {
4756        use camel_component_api::ConsumerContext;
4757
4758        // Get an OS-assigned free port
4759        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4760        let port = listener.local_addr().unwrap().port();
4761        drop(listener); // Release port — ServerRegistry will rebind it
4762
4763        let consumer_cfg = HttpServerConfig {
4764            scheme: "http".to_string(),
4765            host: "127.0.0.1".to_string(),
4766            port,
4767            path: "/ping".to_string(),
4768            max_request_body: 2 * 1024 * 1024,
4769            max_response_body: 10 * 1024 * 1024,
4770            max_inflight_requests: 1024,
4771            method: None,
4772            tls_config: None,
4773        };
4774        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4775
4776        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4777        let token = tokio_util::sync::CancellationToken::new();
4778        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4779
4780        tokio::spawn(async move {
4781            consumer.start(ctx).await.unwrap();
4782        });
4783
4784        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4785
4786        let client = reqwest::Client::new();
4787        let resp_future = client
4788            .post(format!("http://127.0.0.1:{port}/ping"))
4789            .body("hello world")
4790            .send();
4791
4792        let (http_result, _) = tokio::join!(resp_future, async {
4793            if let Some(mut envelope) = rx.recv().await {
4794                // Set a custom status code
4795                envelope.exchange.input.set_header(
4796                    "CamelHttpResponseCode",
4797                    serde_json::Value::Number(201.into()),
4798                );
4799                if let Some(reply_tx) = envelope.reply_tx {
4800                    let _ = reply_tx.send(Ok(envelope.exchange));
4801                }
4802            }
4803        });
4804
4805        let resp = http_result.unwrap();
4806        assert_eq!(resp.status().as_u16(), 201);
4807
4808        token.cancel();
4809    }
4810
4811    /// rc-3y6j: the RequestEnvelope channel capacity must mirror the
4812    /// dispatcher's inflight semaphore so the semaphore stays the single
4813    /// backpressure point. Zero maps to 1 because `mpsc::channel(0)` panics.
4814    #[test]
4815    fn test_envelope_channel_capacity_follows_max_inflight() {
4816        assert_eq!(envelope_channel_capacity(0), 1);
4817        assert_eq!(envelope_channel_capacity(1), 1);
4818        assert_eq!(envelope_channel_capacity(7), 7);
4819        assert_eq!(envelope_channel_capacity(64), 64);
4820        assert_eq!(envelope_channel_capacity(1024), 1024);
4821    }
4822
4823    /// rc-3y6j: `maxInflightRequests=0` is a representable reject-everything
4824    /// configuration. Consumer start must not panic on it (the channel guard)
4825    /// and every request must get 503 from the empty semaphore.
4826    #[tokio::test]
4827    async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
4828        use camel_component_api::ConsumerContext;
4829
4830        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4831        let port = listener.local_addr().unwrap().port();
4832        drop(listener);
4833
4834        let consumer_cfg = HttpServerConfig {
4835            scheme: "http".to_string(),
4836            host: "127.0.0.1".to_string(),
4837            port,
4838            path: "/ping".to_string(),
4839            max_request_body: 2 * 1024 * 1024,
4840            max_response_body: 10 * 1024 * 1024,
4841            max_inflight_requests: 0,
4842            method: None,
4843            tls_config: None,
4844        };
4845        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4846
4847        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4848        let token = tokio_util::sync::CancellationToken::new();
4849        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4850
4851        let start_handle = tokio::spawn(async move {
4852            consumer.start(ctx).await.unwrap();
4853        });
4854
4855        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4856
4857        let client = reqwest::Client::new();
4858        let resp = client
4859            .post(format!("http://127.0.0.1:{port}/ping"))
4860            .body("hello world")
4861            .send()
4862            .await
4863            .unwrap();
4864        assert_eq!(resp.status().as_u16(), 503);
4865
4866        token.cancel();
4867        let _ = start_handle.await;
4868    }
4869
4870    /// rc-w1u9: HttpConsumer MUST declare Explicit startup_mode so the runtime
4871    /// waits for the listener bind before publishing RouteStarted.
4872    #[test]
4873    fn test_http_consumer_startup_mode_is_explicit() {
4874        use camel_component_api::ConsumerStartupMode;
4875        let consumer_cfg = HttpServerConfig {
4876            scheme: "http".to_string(),
4877            host: "127.0.0.1".to_string(),
4878            port: 0,
4879            path: "/x".to_string(),
4880            max_request_body: 2 * 1024 * 1024,
4881            max_response_body: 10 * 1024 * 1024,
4882            max_inflight_requests: 1024,
4883            method: None,
4884            tls_config: None,
4885        };
4886        let consumer = HttpConsumer::new(consumer_cfg, test_rt());
4887        assert_eq!(
4888            consumer.startup_mode(),
4889            ConsumerStartupMode::Explicit,
4890            "HttpConsumer must opt into Explicit startup"
4891        );
4892    }
4893
4894    /// rc-w1u9: HttpConsumer::start() MUST call ctx.mark_ready() AFTER bind
4895    /// + route registration. The StartupSignal resolves Ok only when that
4896    /// happens. Verified here by injecting our own signal pair into the
4897    /// ConsumerContext and asserting the receiver resolves within a bounded
4898    /// window even before any HTTP request is made.
4899    #[allow(clippy::await_holding_lock)]
4900    #[tokio::test]
4901    async fn test_http_consumer_emits_mark_ready_after_bind() {
4902        use camel_component_api::{ConsumerContext, StartupSignal};
4903
4904        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4905
4906        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4907        let port = listener.local_addr().unwrap().port();
4908        drop(listener);
4909
4910        let consumer_cfg = HttpServerConfig {
4911            scheme: "http".to_string(),
4912            host: "127.0.0.1".to_string(),
4913            port,
4914            path: "/ready-probe".to_string(),
4915            max_request_body: 2 * 1024 * 1024,
4916            max_response_body: 10 * 1024 * 1024,
4917            max_inflight_requests: 1024,
4918            method: None,
4919            tls_config: None,
4920        };
4921        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4922
4923        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4924        let token = tokio_util::sync::CancellationToken::new();
4925        let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
4926
4927        // Inject our own startup signal so we can observe mark_ready.
4928        let (signal, startup_rx) = StartupSignal::pair();
4929        let ctx = ctx.with_startup(signal);
4930
4931        // Spawn start() — it MUST call mark_ready once the listener is bound
4932        // and the path is registered.
4933        tokio::spawn(async move {
4934            let _ = consumer.start(ctx).await;
4935        });
4936
4937        // The receiver MUST resolve Ok within a bounded window — proving
4938        // mark_ready was called by start(). A short timeout catches the
4939        // regression where mark_ready is never called (the old behaviour
4940        // would hang the receiver forever, which is exactly the rc-w1u9 bug).
4941        let result =
4942            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
4943                .await
4944                .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
4945        assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
4946
4947        // Cancellation tears down the spawned start() loop.
4948        token.cancel();
4949    }
4950
4951    #[tokio::test]
4952    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
4953        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4954
4955        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4956        let port = listener.local_addr().unwrap().port();
4957        drop(listener);
4958
4959        let consumer_cfg = HttpServerConfig {
4960            scheme: "http".to_string(),
4961            host: "127.0.0.1".to_string(),
4962            port,
4963            path: "/saturation".to_string(),
4964            max_request_body: 2 * 1024 * 1024,
4965            max_response_body: 10 * 1024 * 1024,
4966            max_inflight_requests: 1,
4967            method: None,
4968            tls_config: None,
4969        };
4970        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4971
4972        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4973        let token = tokio_util::sync::CancellationToken::new();
4974        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4975        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4976        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4977
4978        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
4979        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
4980
4981        tokio::spawn(async move {
4982            let mut first_seen_tx = Some(first_seen_tx);
4983            let mut unblock_first_rx = Some(unblock_first_rx);
4984
4985            while let Some(envelope) = rx.recv().await {
4986                if let Some(tx) = first_seen_tx.take() {
4987                    let _ = tx.send(());
4988                    if let Some(rx_unblock) = unblock_first_rx.take() {
4989                        let _ = rx_unblock.await;
4990                    }
4991                }
4992
4993                if let Some(reply_tx) = envelope.reply_tx {
4994                    let _ = reply_tx.send(Ok(envelope.exchange));
4995                }
4996            }
4997        });
4998
4999        let client = reqwest::Client::new();
5000        let first_req = {
5001            let client = client.clone();
5002            async move {
5003                client
5004                    .get(format!("http://127.0.0.1:{port}/saturation"))
5005                    .send()
5006                    .await
5007                    .unwrap()
5008            }
5009        };
5010
5011        let first_handle = tokio::spawn(first_req);
5012        first_seen_rx.await.unwrap();
5013
5014        let second_resp = client
5015            .get(format!("http://127.0.0.1:{port}/saturation"))
5016            .send()
5017            .await
5018            .unwrap();
5019
5020        assert_eq!(second_resp.status().as_u16(), 503);
5021
5022        let _ = unblock_first_tx.send(());
5023        let first_resp = first_handle.await.unwrap();
5024        assert_eq!(first_resp.status().as_u16(), 200);
5025
5026        token.cancel();
5027    }
5028
5029    #[tokio::test]
5030    #[allow(clippy::await_holding_lock)]
5031    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
5032        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5033
5034        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5035
5036        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5037        let port = listener.local_addr().unwrap().port();
5038        drop(listener);
5039
5040        let consumer_cfg = HttpServerConfig {
5041            scheme: "http".to_string(),
5042            host: "127.0.0.1".to_string(),
5043            port,
5044            path: "/limit-bytes".to_string(),
5045            max_request_body: 2 * 1024 * 1024,
5046            max_response_body: 16,
5047            max_inflight_requests: 1024,
5048            method: None,
5049            tls_config: None,
5050        };
5051        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5052
5053        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5054        let token = tokio_util::sync::CancellationToken::new();
5055        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5056        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5057        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5058
5059        let client = reqwest::Client::new();
5060        let send_fut = client
5061            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
5062            .send();
5063
5064        let (http_result, _) = tokio::join!(send_fut, async {
5065            if let Some(mut envelope) = rx.recv().await {
5066                envelope.exchange.input.body =
5067                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
5068                if let Some(reply_tx) = envelope.reply_tx {
5069                    let _ = reply_tx.send(Ok(envelope.exchange));
5070                }
5071            }
5072        });
5073
5074        let resp = http_result.unwrap();
5075        assert_eq!(resp.status().as_u16(), 500);
5076        let body = resp.text().await.unwrap();
5077        assert_eq!(body, "Response body exceeds configured limit");
5078        token.cancel();
5079    }
5080
5081    #[tokio::test]
5082    #[allow(clippy::await_holding_lock)]
5083    async fn test_http_consumer_enforces_max_response_body_for_json() {
5084        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5085
5086        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5087
5088        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5089        let port = listener.local_addr().unwrap().port();
5090        drop(listener);
5091
5092        let consumer_cfg = HttpServerConfig {
5093            scheme: "http".to_string(),
5094            host: "127.0.0.1".to_string(),
5095            port,
5096            path: "/limit-json".to_string(),
5097            max_request_body: 2 * 1024 * 1024,
5098            max_response_body: 16,
5099            max_inflight_requests: 1024,
5100            method: None,
5101            tls_config: None,
5102        };
5103        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5104
5105        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5106        let token = tokio_util::sync::CancellationToken::new();
5107        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5108        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5109        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5110
5111        let client = reqwest::Client::new();
5112        let send_fut = client
5113            .get(format!("http://127.0.0.1:{port}/limit-json"))
5114            .send();
5115
5116        let (http_result, _) = tokio::join!(send_fut, async {
5117            if let Some(mut envelope) = rx.recv().await {
5118                envelope.exchange.input.body = camel_component_api::Body::Json(
5119                    serde_json::json!({"message":"this response is bigger than sixteen"}),
5120                );
5121                if let Some(reply_tx) = envelope.reply_tx {
5122                    let _ = reply_tx.send(Ok(envelope.exchange));
5123                }
5124            }
5125        });
5126
5127        let resp = http_result.unwrap();
5128        assert_eq!(resp.status().as_u16(), 500);
5129        let body = resp.text().await.unwrap();
5130        assert_eq!(body, "Response body exceeds configured limit");
5131        token.cancel();
5132    }
5133
5134    #[tokio::test]
5135    #[allow(clippy::await_holding_lock)]
5136    async fn test_http_consumer_enforces_max_response_body_for_xml() {
5137        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5138
5139        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5140
5141        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5142        let port = listener.local_addr().unwrap().port();
5143        drop(listener);
5144
5145        let consumer_cfg = HttpServerConfig {
5146            scheme: "http".to_string(),
5147            host: "127.0.0.1".to_string(),
5148            port,
5149            path: "/limit-xml".to_string(),
5150            max_request_body: 2 * 1024 * 1024,
5151            max_response_body: 16,
5152            max_inflight_requests: 1024,
5153            method: None,
5154            tls_config: None,
5155        };
5156        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5157
5158        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5159        let token = tokio_util::sync::CancellationToken::new();
5160        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5161        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5162        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5163
5164        let client = reqwest::Client::new();
5165        let send_fut = client
5166            .get(format!("http://127.0.0.1:{port}/limit-xml"))
5167            .send();
5168
5169        let (http_result, _) = tokio::join!(send_fut, async {
5170            if let Some(mut envelope) = rx.recv().await {
5171                envelope.exchange.input.body = camel_component_api::Body::Xml(
5172                    "<root><value>way-too-large</value></root>".into(),
5173                );
5174                if let Some(reply_tx) = envelope.reply_tx {
5175                    let _ = reply_tx.send(Ok(envelope.exchange));
5176                }
5177            }
5178        });
5179
5180        let resp = http_result.unwrap();
5181        assert_eq!(resp.status().as_u16(), 500);
5182        let body = resp.text().await.unwrap();
5183        assert_eq!(body, "Response body exceeds configured limit");
5184        token.cancel();
5185    }
5186
5187    #[tokio::test]
5188    #[allow(clippy::await_holding_lock)]
5189    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
5190        use camel_component_api::{
5191            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
5192        };
5193        use futures::stream;
5194
5195        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5196
5197        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
5198        let port = listener.local_addr().unwrap().port();
5199        drop(listener);
5200
5201        let consumer_cfg = HttpServerConfig {
5202            scheme: "http".to_string(),
5203            host: "0.0.0.0".to_string(),
5204            port,
5205            path: "/limit-stream".to_string(),
5206            max_request_body: 2 * 1024 * 1024,
5207            max_response_body: 16,
5208            max_inflight_requests: 1024,
5209            method: None,
5210            tls_config: None,
5211        };
5212        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5213
5214        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5215        let token = tokio_util::sync::CancellationToken::new();
5216        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5217        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5218        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5219
5220        let client = reqwest::Client::new();
5221        let send_fut = client
5222            .get(format!("http://127.0.0.1:{port}/limit-stream"))
5223            .send();
5224
5225        let (http_result, _) = tokio::join!(send_fut, async {
5226            if let Some(mut envelope) = rx.recv().await {
5227                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5228                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
5229                let stream = Box::pin(stream::iter(chunks));
5230                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
5231                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5232                    metadata: StreamMetadata {
5233                        size_hint: Some(32),
5234                        content_type: Some("application/octet-stream".into()),
5235                        origin: None,
5236                    },
5237                });
5238                if let Some(reply_tx) = envelope.reply_tx {
5239                    let _ = reply_tx.send(Ok(envelope.exchange));
5240                }
5241            }
5242        });
5243
5244        let resp = http_result.unwrap();
5245        assert_eq!(resp.status().as_u16(), 200);
5246        let body = resp.bytes().await.unwrap();
5247        assert_eq!(body.len(), 32);
5248        token.cancel();
5249    }
5250
5251    // -----------------------------------------------------------------------
5252    // Integration tests
5253    // -----------------------------------------------------------------------
5254
5255    #[tokio::test]
5256    #[allow(clippy::await_holding_lock)]
5257    async fn test_integration_single_consumer_round_trip() {
5258        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5259
5260        // Spawns an HTTP consumer on the global ServerRegistry
5261        // (HttpConsumer::start → get_or_spawn). Serialize against the other
5262        // registry tests so parallel runs do not race on shared global state.
5263        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5264
5265        // Get an OS-assigned free port (ephemeral)
5266        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5267        let port = listener.local_addr().unwrap().port();
5268        drop(listener); // Release — ServerRegistry will rebind
5269
5270        let component = HttpComponent::new();
5271        let endpoint_ctx = NoOpComponentContext;
5272        let endpoint = component
5273            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
5274            .unwrap();
5275        let mut consumer = endpoint.create_consumer(rt()).unwrap();
5276
5277        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5278        let token = tokio_util::sync::CancellationToken::new();
5279        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5280
5281        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5282        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5283
5284        let client = reqwest::Client::new();
5285        let send_fut = client
5286            .post(format!("http://127.0.0.1:{port}/echo"))
5287            .header("Content-Type", "text/plain")
5288            .body("ping")
5289            .send();
5290
5291        let (http_result, _) = tokio::join!(send_fut, async {
5292            if let Some(mut envelope) = rx.recv().await {
5293                assert_eq!(
5294                    envelope.exchange.input.header("CamelHttpMethod"),
5295                    Some(&serde_json::Value::String("POST".into()))
5296                );
5297                assert_eq!(
5298                    envelope.exchange.input.header("CamelHttpPath"),
5299                    Some(&serde_json::Value::String("/echo".into()))
5300                );
5301                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
5302                if let Some(reply_tx) = envelope.reply_tx {
5303                    let _ = reply_tx.send(Ok(envelope.exchange));
5304                }
5305            }
5306        });
5307
5308        let resp = http_result.unwrap();
5309        assert_eq!(resp.status().as_u16(), 200);
5310        let body = resp.text().await.unwrap();
5311        assert_eq!(body, "pong");
5312
5313        token.cancel();
5314    }
5315
5316    #[tokio::test]
5317    #[allow(clippy::await_holding_lock)]
5318    async fn test_integration_two_consumers_shared_port() {
5319        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5320
5321        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5322
5323        // Get an OS-assigned free port (ephemeral)
5324        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5325        let port = listener.local_addr().unwrap().port();
5326        drop(listener);
5327
5328        let component = HttpComponent::new();
5329        let endpoint_ctx = NoOpComponentContext;
5330
5331        // Consumer A: /hello
5332        let endpoint_a = component
5333            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
5334            .unwrap();
5335        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
5336
5337        // Consumer B: /world
5338        let endpoint_b = component
5339            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
5340            .unwrap();
5341        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
5342
5343        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5344        let token_a = tokio_util::sync::CancellationToken::new();
5345        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
5346
5347        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5348        let token_b = tokio_util::sync::CancellationToken::new();
5349        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
5350
5351        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
5352        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
5353        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5354
5355        let client = reqwest::Client::new();
5356
5357        // Request to /hello
5358        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
5359        let (resp_hello, _) = tokio::join!(fut_hello, async {
5360            if let Some(mut envelope) = rx_a.recv().await {
5361                envelope.exchange.input.body =
5362                    camel_component_api::Body::Text("hello-response".to_string());
5363                if let Some(reply_tx) = envelope.reply_tx {
5364                    let _ = reply_tx.send(Ok(envelope.exchange));
5365                }
5366            }
5367        });
5368
5369        // Request to /world
5370        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
5371        let (resp_world, _) = tokio::join!(fut_world, async {
5372            if let Some(mut envelope) = rx_b.recv().await {
5373                envelope.exchange.input.body =
5374                    camel_component_api::Body::Text("world-response".to_string());
5375                if let Some(reply_tx) = envelope.reply_tx {
5376                    let _ = reply_tx.send(Ok(envelope.exchange));
5377                }
5378            }
5379        });
5380
5381        let body_a = resp_hello.unwrap().text().await.unwrap();
5382        let body_b = resp_world.unwrap().text().await.unwrap();
5383
5384        assert_eq!(body_a, "hello-response");
5385        assert_eq!(body_b, "world-response");
5386
5387        token_a.cancel();
5388        token_b.cancel();
5389    }
5390
5391    #[tokio::test]
5392    #[allow(clippy::await_holding_lock)]
5393    async fn test_integration_unregistered_path_returns_404() {
5394        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5395
5396        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5397
5398        // Get an OS-assigned free port (ephemeral)
5399        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5400        let port = listener.local_addr().unwrap().port();
5401        drop(listener);
5402
5403        let component = HttpComponent::new();
5404        let endpoint_ctx = NoOpComponentContext;
5405        let endpoint = component
5406            .create_endpoint(
5407                &format!("http://127.0.0.1:{port}/registered"),
5408                &endpoint_ctx,
5409            )
5410            .unwrap();
5411        let mut consumer = endpoint.create_consumer(rt()).unwrap();
5412
5413        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5414        let token = tokio_util::sync::CancellationToken::new();
5415        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5416
5417        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5418
5419        // Wait until the server is actually accepting connections (CI runners can be slow).
5420        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
5421        loop {
5422            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
5423                .await
5424                .is_ok()
5425            {
5426                break;
5427            }
5428            if std::time::Instant::now() >= deadline {
5429                panic!("HTTP server did not start within 5s on port {port}");
5430            }
5431            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5432        }
5433
5434        let client = reqwest::Client::new();
5435        let resp = client
5436            .get(format!("http://127.0.0.1:{port}/not-there"))
5437            .send()
5438            .await
5439            .unwrap();
5440        assert_eq!(resp.status().as_u16(), 404);
5441
5442        token.cancel();
5443    }
5444
5445    #[test]
5446    fn test_http_consumer_declares_concurrent() {
5447        use camel_component_api::ConcurrencyModel;
5448
5449        let config = HttpServerConfig {
5450            scheme: "http".to_string(),
5451            host: "127.0.0.1".to_string(),
5452            port: 19999,
5453            path: "/test".to_string(),
5454            max_request_body: 2 * 1024 * 1024,
5455            max_response_body: 10 * 1024 * 1024,
5456            max_inflight_requests: 1024,
5457            method: None,
5458            tls_config: None,
5459        };
5460        let consumer = HttpConsumer::new(config, test_rt());
5461        assert_eq!(
5462            consumer.concurrency_model(),
5463            ConcurrencyModel::Concurrent { max: None }
5464        );
5465    }
5466
5467    #[test]
5468    fn server_config_parses_tls_cert_and_key() {
5469        let cfg = HttpServerConfig::from_uri(
5470            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
5471        )
5472        .unwrap();
5473        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
5474        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
5475    }
5476
5477    #[test]
5478    fn server_config_no_tls_when_params_absent() {
5479        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
5480        assert!(cfg.tls_config.is_none());
5481    }
5482
5483    // -----------------------------------------------------------------------
5484    // HttpReplyBody streaming tests
5485    // -----------------------------------------------------------------------
5486
5487    #[tokio::test]
5488    async fn test_http_reply_body_stream_variant_exists() {
5489        use bytes::Bytes;
5490        use camel_component_api::CamelError;
5491        use futures::stream;
5492
5493        let chunks: Vec<Result<Bytes, CamelError>> =
5494            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
5495        let stream = Box::pin(stream::iter(chunks));
5496        let reply_body = HttpReplyBody::Stream(stream);
5497        // Si compila y el match funciona, el test pasa
5498        match reply_body {
5499            HttpReplyBody::Stream(_) => {}
5500            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
5501        }
5502    }
5503
5504    // -----------------------------------------------------------------------
5505    // OpenTelemetry propagation tests (only compiled with "otel" feature)
5506    // -----------------------------------------------------------------------
5507
5508    #[cfg(feature = "otel")]
5509    mod otel_tests {
5510        use super::*;
5511        use camel_component_api::Message;
5512        use tower::ServiceExt;
5513
5514        #[tokio::test]
5515        async fn test_producer_injects_traceparent_header() {
5516            let (url, _handle) = start_test_server_with_header_capture().await;
5517            let ctx = test_producer_ctx();
5518
5519            let component = HttpComponent::new();
5520            let endpoint_ctx = NoOpComponentContext;
5521            let endpoint = component
5522                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5523                .unwrap();
5524            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5525
5526            // Create exchange with an OTel context by extracting from a traceparent header
5527            let mut exchange = Exchange::new(Message::default());
5528            let mut headers = std::collections::HashMap::new();
5529            headers.insert(
5530                "traceparent".to_string(),
5531                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
5532            );
5533            camel_otel::extract_into_exchange(&mut exchange, &headers);
5534
5535            let result = producer.oneshot(exchange).await.unwrap();
5536
5537            // Verify request succeeded
5538            let status = result
5539                .input
5540                .header("CamelHttpResponseCode")
5541                .and_then(|v| v.as_u64())
5542                .unwrap();
5543            assert_eq!(status, 200);
5544
5545            // The test server echoes back the received traceparent header
5546            let traceparent = result.input.header("X-Received-Traceparent");
5547            assert!(
5548                traceparent.is_some(),
5549                "traceparent header should have been sent"
5550            );
5551
5552            let traceparent_str = traceparent.unwrap().as_str().unwrap();
5553            // Verify format: version-traceid-spanid-flags
5554            let parts: Vec<&str> = traceparent_str.split('-').collect();
5555            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5556            assert_eq!(parts[0], "00", "version should be 00");
5557            assert_eq!(
5558                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5559                "trace-id should match"
5560            );
5561            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
5562            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
5563        }
5564
5565        #[tokio::test]
5566        async fn test_consumer_extracts_traceparent_header() {
5567            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5568
5569            // Get an OS-assigned free port
5570            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5571            let port = listener.local_addr().unwrap().port();
5572            drop(listener);
5573
5574            let component = HttpComponent::new();
5575            let endpoint_ctx = NoOpComponentContext;
5576            let endpoint = component
5577                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5578                .unwrap();
5579            let mut consumer = endpoint.create_consumer(rt()).unwrap();
5580
5581            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5582            let token = tokio_util::sync::CancellationToken::new();
5583            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5584
5585            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5586            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5587
5588            // Send request with traceparent header
5589            let client = reqwest::Client::new();
5590            let send_fut = client
5591                .post(format!("http://127.0.0.1:{port}/trace"))
5592                .header(
5593                    "traceparent",
5594                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5595                )
5596                .body("test")
5597                .send();
5598
5599            let (http_result, _) = tokio::join!(send_fut, async {
5600                if let Some(envelope) = rx.recv().await {
5601                    // Verify the exchange has a valid OTel context by re-injecting it
5602                    // and checking the traceparent matches
5603                    let mut injected_headers = std::collections::HashMap::new();
5604                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5605
5606                    assert!(
5607                        injected_headers.contains_key("traceparent"),
5608                        "Exchange should have traceparent after extraction"
5609                    );
5610
5611                    let traceparent = injected_headers.get("traceparent").unwrap();
5612                    let parts: Vec<&str> = traceparent.split('-').collect();
5613                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5614                    assert_eq!(
5615                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5616                        "Trace ID should match the original traceparent header"
5617                    );
5618
5619                    if let Some(reply_tx) = envelope.reply_tx {
5620                        let _ = reply_tx.send(Ok(envelope.exchange));
5621                    }
5622                }
5623            });
5624
5625            let resp = http_result.unwrap();
5626            assert_eq!(resp.status().as_u16(), 200);
5627
5628            token.cancel();
5629        }
5630
5631        #[tokio::test]
5632        async fn test_consumer_extracts_mixed_case_traceparent_header() {
5633            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5634
5635            // Get an OS-assigned free port
5636            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5637            let port = listener.local_addr().unwrap().port();
5638            drop(listener);
5639
5640            let component = HttpComponent::new();
5641            let endpoint_ctx = NoOpComponentContext;
5642            let endpoint = component
5643                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5644                .unwrap();
5645            let mut consumer = endpoint.create_consumer(rt()).unwrap();
5646
5647            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5648            let token = tokio_util::sync::CancellationToken::new();
5649            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5650
5651            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5652            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5653
5654            // Send request with MIXED-CASE TraceParent header (not lowercase)
5655            let client = reqwest::Client::new();
5656            let send_fut = client
5657                .post(format!("http://127.0.0.1:{port}/trace"))
5658                .header(
5659                    "TraceParent",
5660                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5661                )
5662                .body("test")
5663                .send();
5664
5665            let (http_result, _) = tokio::join!(send_fut, async {
5666                if let Some(envelope) = rx.recv().await {
5667                    // Verify the exchange has a valid OTel context by re-injecting it
5668                    // and checking the traceparent matches
5669                    let mut injected_headers = HashMap::new();
5670                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5671
5672                    assert!(
5673                        injected_headers.contains_key("traceparent"),
5674                        "Exchange should have traceparent after extraction from mixed-case header"
5675                    );
5676
5677                    let traceparent = injected_headers.get("traceparent").unwrap();
5678                    let parts: Vec<&str> = traceparent.split('-').collect();
5679                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5680                    assert_eq!(
5681                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5682                        "Trace ID should match the original mixed-case TraceParent header"
5683                    );
5684
5685                    if let Some(reply_tx) = envelope.reply_tx {
5686                        let _ = reply_tx.send(Ok(envelope.exchange));
5687                    }
5688                }
5689            });
5690
5691            let resp = http_result.unwrap();
5692            assert_eq!(resp.status().as_u16(), 200);
5693
5694            token.cancel();
5695        }
5696
5697        #[tokio::test]
5698        async fn test_producer_no_trace_context_no_crash() {
5699            let (url, _handle) = start_test_server().await;
5700            let ctx = test_producer_ctx();
5701
5702            let component = HttpComponent::new();
5703            let endpoint_ctx = NoOpComponentContext;
5704            let endpoint = component
5705                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5706                .unwrap();
5707            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5708
5709            // Create exchange with default (empty) otel_context - no trace context
5710            let exchange = Exchange::new(Message::default());
5711
5712            // Should succeed without panic
5713            let result = producer.oneshot(exchange).await.unwrap();
5714
5715            // Verify request succeeded
5716            let status = result
5717                .input
5718                .header("CamelHttpResponseCode")
5719                .and_then(|v| v.as_u64())
5720                .unwrap();
5721            assert_eq!(status, 200);
5722        }
5723
5724        /// Test server that captures and echoes back the traceparent header
5725        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
5726            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5727            let addr = listener.local_addr().unwrap();
5728            let url = format!("http://127.0.0.1:{}", addr.port());
5729
5730            let handle = tokio::spawn(async move {
5731                loop {
5732                    if let Ok((mut stream, _)) = listener.accept().await {
5733                        tokio::spawn(async move {
5734                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5735                            let mut buf = vec![0u8; 8192];
5736                            let n = stream.read(&mut buf).await.unwrap_or(0);
5737                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
5738
5739                            // Extract traceparent header from request
5740                            let traceparent = request
5741                                .lines()
5742                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
5743                                .map(|line| {
5744                                    line.split(':')
5745                                        .nth(1)
5746                                        .map(|s| s.trim().to_string())
5747                                        .unwrap_or_default()
5748                                })
5749                                .unwrap_or_default();
5750
5751                            let body =
5752                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
5753                            let response = format!(
5754                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
5755                                body.len(),
5756                                traceparent,
5757                                body
5758                            );
5759                            let _ = stream.write_all(response.as_bytes()).await;
5760                        });
5761                    }
5762                }
5763            });
5764
5765            (url, handle)
5766        }
5767    }
5768
5769    // -----------------------------------------------------------------------
5770    // Response streaming tests (Eje A - Task 2)
5771    // -----------------------------------------------------------------------
5772
5773    // -----------------------------------------------------------------------
5774    // Request streaming tests (Eje B - Task 3)
5775    // -----------------------------------------------------------------------
5776
5777    #[tokio::test]
5778    async fn test_request_body_arrives_as_stream() {
5779        use camel_component_api::Body;
5780        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5781
5782        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5783        let port = listener.local_addr().unwrap().port();
5784        drop(listener);
5785
5786        let component = HttpComponent::new();
5787        let endpoint_ctx = NoOpComponentContext;
5788        let endpoint = component
5789            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
5790            .unwrap();
5791        let mut consumer = endpoint.create_consumer(rt()).unwrap();
5792
5793        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5794        let token = tokio_util::sync::CancellationToken::new();
5795        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5796
5797        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5798        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5799
5800        let client = reqwest::Client::new();
5801        let send_fut = client
5802            .post(format!("http://127.0.0.1:{port}/upload"))
5803            .body("hello streaming world")
5804            .send();
5805
5806        let (http_result, _) = tokio::join!(send_fut, async {
5807            if let Some(mut envelope) = rx.recv().await {
5808                // Body must be Body::Stream, not Body::Text or Body::Bytes
5809                assert!(
5810                    matches!(envelope.exchange.input.body, Body::Stream(_)),
5811                    "expected Body::Stream, got discriminant {:?}",
5812                    std::mem::discriminant(&envelope.exchange.input.body)
5813                );
5814                // Materialize to verify content
5815                let bytes = envelope
5816                    .exchange
5817                    .input
5818                    .body
5819                    .into_bytes(1024 * 1024)
5820                    .await
5821                    .unwrap();
5822                assert_eq!(&bytes[..], b"hello streaming world");
5823
5824                envelope.exchange.input.body = camel_component_api::Body::Empty;
5825                if let Some(reply_tx) = envelope.reply_tx {
5826                    let _ = reply_tx.send(Ok(envelope.exchange));
5827                }
5828            }
5829        });
5830
5831        let resp = http_result.unwrap();
5832        assert_eq!(resp.status().as_u16(), 200);
5833
5834        token.cancel();
5835    }
5836
5837    // -----------------------------------------------------------------------
5838    // Response streaming tests (Eje A - Task 2)
5839    // -----------------------------------------------------------------------
5840
5841    #[tokio::test]
5842    async fn test_streaming_response_chunked() {
5843        use bytes::Bytes;
5844        use camel_component_api::Body;
5845        use camel_component_api::CamelError;
5846        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5847        use camel_component_api::{StreamBody, StreamMetadata};
5848        use futures::stream;
5849        use std::sync::Arc;
5850        use tokio::sync::Mutex;
5851
5852        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5853        let port = listener.local_addr().unwrap().port();
5854        drop(listener);
5855
5856        let component = HttpComponent::new();
5857        let endpoint_ctx = NoOpComponentContext;
5858        let endpoint = component
5859            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
5860            .unwrap();
5861        let mut consumer = endpoint.create_consumer(rt()).unwrap();
5862
5863        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5864        let token = tokio_util::sync::CancellationToken::new();
5865        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5866
5867        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5868        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5869
5870        let client = reqwest::Client::new();
5871        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
5872
5873        let (http_result, _) = tokio::join!(send_fut, async {
5874            if let Some(mut envelope) = rx.recv().await {
5875                // Respond with Body::Stream
5876                let chunks: Vec<Result<Bytes, CamelError>> =
5877                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
5878                let stream = Box::pin(stream::iter(chunks));
5879                envelope.exchange.input.body = Body::Stream(StreamBody {
5880                    stream: Arc::new(Mutex::new(Some(stream))),
5881                    metadata: StreamMetadata::default(),
5882                });
5883                if let Some(reply_tx) = envelope.reply_tx {
5884                    let _ = reply_tx.send(Ok(envelope.exchange));
5885                }
5886            }
5887        });
5888
5889        let resp = http_result.unwrap();
5890        assert_eq!(resp.status().as_u16(), 200);
5891        let body = resp.text().await.unwrap();
5892        assert_eq!(body, "chunk1chunk2");
5893
5894        token.cancel();
5895    }
5896
5897    // -----------------------------------------------------------------------
5898    // 413 Content-Length limit test (Task 4)
5899    // -----------------------------------------------------------------------
5900
5901    #[tokio::test]
5902    async fn test_413_when_content_length_exceeds_limit() {
5903        use camel_component_api::ConsumerContext;
5904
5905        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5906        let port = listener.local_addr().unwrap().port();
5907        drop(listener);
5908
5909        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
5910        let component = HttpComponent::new();
5911        let endpoint_ctx = NoOpComponentContext;
5912        let endpoint = component
5913            .create_endpoint(
5914                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
5915                &endpoint_ctx,
5916            )
5917            .unwrap();
5918        let mut consumer = endpoint.create_consumer(rt()).unwrap();
5919
5920        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5921        let token = tokio_util::sync::CancellationToken::new();
5922        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5923
5924        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5925        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5926
5927        let client = reqwest::Client::new();
5928        let resp = client
5929            .post(format!("http://127.0.0.1:{port}/upload"))
5930            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
5931            .body("x".repeat(1000))
5932            .send()
5933            .await
5934            .unwrap();
5935
5936        assert_eq!(resp.status().as_u16(), 413);
5937
5938        token.cancel();
5939    }
5940
5941    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
5942    /// The spec says: "If there is no Content-Length, the limit does not apply at the
5943    /// consumer level — the route is responsible."
5944    #[tokio::test]
5945    async fn test_chunked_upload_without_content_length_bypasses_limit() {
5946        use bytes::Bytes;
5947        use camel_component_api::Body;
5948        use camel_component_api::ConsumerContext;
5949        use futures::stream;
5950
5951        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5952        let port = listener.local_addr().unwrap().port();
5953        drop(listener);
5954
5955        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
5956        let component = HttpComponent::new();
5957        let endpoint_ctx = NoOpComponentContext;
5958        let endpoint = component
5959            .create_endpoint(
5960                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
5961                &endpoint_ctx,
5962            )
5963            .unwrap();
5964        let mut consumer = endpoint.create_consumer(rt()).unwrap();
5965
5966        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5967        let token = tokio_util::sync::CancellationToken::new();
5968        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5969
5970        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5971        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5972
5973        let client = reqwest::Client::new();
5974
5975        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
5976        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
5977        // but since there's no Content-Length the 413 check must NOT fire.
5978        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
5979            Ok(Bytes::from("y".repeat(50))),
5980            Ok(Bytes::from("y".repeat(50))),
5981        ];
5982        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
5983        let send_fut = client
5984            .post(format!("http://127.0.0.1:{port}/upload"))
5985            .body(stream_body)
5986            .send();
5987
5988        let consumer_fut = async {
5989            // Use timeout to avoid deadlock if the handler rejects before enqueueing
5990            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
5991                Ok(Some(mut envelope)) => {
5992                    assert!(
5993                        matches!(envelope.exchange.input.body, Body::Stream(_)),
5994                        "expected Body::Stream"
5995                    );
5996                    envelope.exchange.input.body = camel_component_api::Body::Empty;
5997                    if let Some(reply_tx) = envelope.reply_tx {
5998                        let _ = reply_tx.send(Ok(envelope.exchange));
5999                    }
6000                }
6001                Ok(None) => panic!("consumer channel closed unexpectedly"),
6002                Err(_) => {
6003                    // Timeout: the request was rejected before reaching the consumer.
6004                    // The HTTP response will carry the real status code (we check below).
6005                }
6006            }
6007        };
6008
6009        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
6010
6011        let resp = http_result.unwrap();
6012        // Must NOT be 413; chunked uploads without Content-Length bypass the limit.
6013        assert_ne!(
6014            resp.status().as_u16(),
6015            413,
6016            "chunked upload must not be rejected by maxRequestBody"
6017        );
6018        assert_eq!(resp.status().as_u16(), 200);
6019
6020        token.cancel();
6021    }
6022
6023    #[test]
6024    fn test_is_private_ip_ranges() {
6025        use camel_api::is_ssrf_blocked_ip;
6026        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
6027        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
6028        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
6029        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
6030        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
6031        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
6032
6033        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
6034        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
6035        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
6036        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
6037        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
6038        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
6039        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
6040        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
6041
6042        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
6043        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
6044        assert!(!is_ssrf_blocked_ip(
6045            &"2001:4860:4860::8888".parse().unwrap()
6046        )); // allow-unwrap
6047    }
6048
6049    #[test]
6050    fn test_title_case_header() {
6051        assert_eq!(title_case_header("content-type"), "Content-Type");
6052        assert_eq!(title_case_header("authorization"), "Authorization");
6053        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
6054        assert_eq!(title_case_header("host"), "Host");
6055        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
6056        assert_eq!(title_case_header("single"), "Single");
6057        assert_eq!(title_case_header(""), "");
6058    }
6059
6060    #[test]
6061    fn test_resolve_url_combines_path_and_query_sources() {
6062        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
6063        let mut exchange = Exchange::new(Message::default());
6064        exchange.input.set_header(
6065            "CamelHttpPath",
6066            serde_json::Value::String("next".to_string()),
6067        );
6068        let url = HttpProducer::resolve_url(&exchange, &cfg);
6069        assert!(url.starts_with("http://example.com/base/next?"));
6070        assert!(url.contains("foo=bar"));
6071
6072        exchange.input.set_header(
6073            "CamelHttpUri",
6074            serde_json::Value::String("http://other.test/root".to_string()),
6075        );
6076        exchange.input.set_header(
6077            "CamelHttpQuery",
6078            serde_json::Value::String("a=1&b=2".to_string()),
6079        );
6080
6081        let override_url = HttpProducer::resolve_url(&exchange, &cfg);
6082        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
6083    }
6084
6085    fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
6086        let mut exchange = Exchange::new(Message::default());
6087        exchange
6088            .input
6089            .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
6090        exchange.input.set_header(
6091            "CamelHttpQuery",
6092            serde_json::Value::String(query.to_string()),
6093        );
6094        exchange
6095    }
6096
6097    #[test]
6098    fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
6099        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6100        cfg.bridge_endpoint = true;
6101        cfg.query_params
6102            .insert("token".to_string(), "secret".to_string());
6103        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6104        let url = HttpProducer::resolve_url(&exchange, &cfg);
6105        assert_eq!(url, "http://x/?token=secret");
6106        assert!(!url.contains("/foo"));
6107        assert!(!url.contains("dropme"));
6108    }
6109
6110    #[test]
6111    fn resolve_url_bridge_endpoint_false_merges_path() {
6112        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6113        cfg.bridge_endpoint = false;
6114        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6115        let url = HttpProducer::resolve_url(&exchange, &cfg);
6116        assert!(url.contains("/foo"), "url should contain /foo: {url}");
6117        assert!(
6118            url.contains("dropme=1"),
6119            "url should contain dropme=1: {url}"
6120        );
6121    }
6122
6123    #[test]
6124    fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
6125        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6126        cfg.bridge_endpoint = true;
6127        let mut exchange = Exchange::new(Message::default());
6128        exchange.input.set_header(
6129            "CamelHttpPath",
6130            serde_json::Value::String("/foo".to_string()),
6131        );
6132        let url = HttpProducer::resolve_url(&exchange, &cfg);
6133        assert_eq!(url, "http://x");
6134        assert!(!url.contains("/foo"));
6135    }
6136
6137    #[test]
6138    fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
6139        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6140        cfg.bridge_endpoint = true;
6141        // query_params stays empty ([])
6142        let mut exchange = Exchange::new(Message::default());
6143        exchange.input.set_header(
6144            "CamelHttpUri",
6145            serde_json::Value::String("http://dest/explicit".to_string()),
6146        );
6147        exchange.input.set_header(
6148            "CamelHttpPath",
6149            serde_json::Value::String("/foo".to_string()),
6150        );
6151        exchange.input.set_header(
6152            "CamelHttpQuery",
6153            serde_json::Value::String("x=1".to_string()),
6154        );
6155        let url = HttpProducer::resolve_url(&exchange, &cfg);
6156        // Under bridgeEndpoint=true ALL exchange URL headers (CamelHttpUri,
6157        // CamelHttpPath, CamelHttpQuery) are ignored; the endpoint base URL
6158        // wins verbatim.
6159        assert_eq!(url, "http://x");
6160    }
6161
6162    #[test]
6163    fn test_http_producer_helpers_status_and_size_boundaries() {
6164        assert!(HttpProducer::is_ok_status(200, (200, 299)));
6165        assert!(HttpProducer::is_ok_status(299, (200, 299)));
6166        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
6167        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
6168
6169        assert!(!exceeds_max_response_body(10, 10));
6170        assert!(exceeds_max_response_body(11, 10));
6171    }
6172
6173    // -----------------------------------------------------------------------
6174    // Content-Type inference tests
6175    // -----------------------------------------------------------------------
6176
6177    async fn setup_consumer_on_free_port(
6178        path: &str,
6179    ) -> (
6180        u16,
6181        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
6182        tokio_util::sync::CancellationToken,
6183    ) {
6184        use camel_component_api::ConsumerContext;
6185
6186        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6187        let port = listener.local_addr().unwrap().port();
6188        drop(listener);
6189
6190        let consumer_cfg = HttpServerConfig {
6191            scheme: "http".to_string(),
6192            host: "127.0.0.1".to_string(),
6193            port,
6194            path: path.to_string(),
6195            max_request_body: 2 * 1024 * 1024,
6196            max_response_body: 10 * 1024 * 1024,
6197            max_inflight_requests: 1024,
6198            method: None,
6199            tls_config: None,
6200        };
6201        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6202
6203        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6204        let token = tokio_util::sync::CancellationToken::new();
6205        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6206
6207        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6208        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6209
6210        (port, rx, token)
6211    }
6212
6213    #[tokio::test]
6214    async fn test_content_type_inferred_for_json_body() {
6215        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
6216
6217        let client = reqwest::Client::new();
6218        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
6219
6220        let (http_result, _) = tokio::join!(send_fut, async {
6221            if let Some(mut envelope) = rx.recv().await {
6222                envelope.exchange.input.body =
6223                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
6224                if let Some(reply_tx) = envelope.reply_tx {
6225                    let _ = reply_tx.send(Ok(envelope.exchange));
6226                }
6227            }
6228        });
6229
6230        let resp = http_result.unwrap();
6231        assert_eq!(resp.status().as_u16(), 200);
6232        let ct = resp
6233            .headers()
6234            .get("content-type")
6235            .expect("Content-Type header should be present");
6236        assert_eq!(ct, "application/json");
6237        let body = resp.text().await.unwrap();
6238        assert_eq!(body, r#"{"message":"hello"}"#);
6239
6240        token.cancel();
6241    }
6242
6243    #[tokio::test]
6244    async fn test_content_type_inferred_for_text_body() {
6245        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
6246
6247        let client = reqwest::Client::new();
6248        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
6249
6250        let (http_result, _) = tokio::join!(send_fut, async {
6251            if let Some(mut envelope) = rx.recv().await {
6252                envelope.exchange.input.body =
6253                    camel_component_api::Body::Text("plain text response".to_string());
6254                if let Some(reply_tx) = envelope.reply_tx {
6255                    let _ = reply_tx.send(Ok(envelope.exchange));
6256                }
6257            }
6258        });
6259
6260        let resp = http_result.unwrap();
6261        assert_eq!(resp.status().as_u16(), 200);
6262        let ct = resp
6263            .headers()
6264            .get("content-type")
6265            .expect("Content-Type header should be present");
6266        assert_eq!(ct, "text/plain; charset=utf-8");
6267        let body = resp.text().await.unwrap();
6268        assert_eq!(body, "plain text response");
6269
6270        token.cancel();
6271    }
6272
6273    #[tokio::test]
6274    async fn test_content_type_inferred_for_xml_body() {
6275        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
6276
6277        let client = reqwest::Client::new();
6278        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
6279
6280        let (http_result, _) = tokio::join!(send_fut, async {
6281            if let Some(mut envelope) = rx.recv().await {
6282                envelope.exchange.input.body =
6283                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
6284                if let Some(reply_tx) = envelope.reply_tx {
6285                    let _ = reply_tx.send(Ok(envelope.exchange));
6286                }
6287            }
6288        });
6289
6290        let resp = http_result.unwrap();
6291        assert_eq!(resp.status().as_u16(), 200);
6292        let ct = resp
6293            .headers()
6294            .get("content-type")
6295            .expect("Content-Type header should be present");
6296        assert_eq!(ct, "application/xml");
6297        let body = resp.text().await.unwrap();
6298        assert_eq!(body, "<root><item>value</item></root>");
6299
6300        token.cancel();
6301    }
6302
6303    #[tokio::test]
6304    async fn test_no_content_type_for_empty_body() {
6305        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
6306
6307        let client = reqwest::Client::new();
6308        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
6309
6310        let (http_result, _) = tokio::join!(send_fut, async {
6311            if let Some(mut envelope) = rx.recv().await {
6312                envelope.exchange.input.body = camel_component_api::Body::Empty;
6313                if let Some(reply_tx) = envelope.reply_tx {
6314                    let _ = reply_tx.send(Ok(envelope.exchange));
6315                }
6316            }
6317        });
6318
6319        let resp = http_result.unwrap();
6320        assert_eq!(resp.status().as_u16(), 200);
6321        assert!(
6322            resp.headers().get("content-type").is_none(),
6323            "Empty body should not set Content-Type"
6324        );
6325
6326        token.cancel();
6327    }
6328
6329    #[tokio::test]
6330    async fn test_no_content_type_for_raw_bytes_body() {
6331        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
6332
6333        let client = reqwest::Client::new();
6334        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
6335
6336        let (http_result, _) = tokio::join!(send_fut, async {
6337            if let Some(mut envelope) = rx.recv().await {
6338                envelope.exchange.input.body =
6339                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
6340                if let Some(reply_tx) = envelope.reply_tx {
6341                    let _ = reply_tx.send(Ok(envelope.exchange));
6342                }
6343            }
6344        });
6345
6346        let resp = http_result.unwrap();
6347        assert_eq!(resp.status().as_u16(), 200);
6348        assert!(
6349            resp.headers().get("content-type").is_none(),
6350            "Raw Bytes body should not set Content-Type"
6351        );
6352
6353        token.cancel();
6354    }
6355
6356    #[tokio::test]
6357    async fn test_content_type_from_stream_metadata() {
6358        use camel_component_api::{StreamBody, StreamMetadata};
6359        use futures::stream;
6360
6361        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
6362
6363        let client = reqwest::Client::new();
6364        let send_fut = client
6365            .get(format!("http://127.0.0.1:{port}/stream-ct"))
6366            .send();
6367
6368        let (http_result, _) = tokio::join!(send_fut, async {
6369            if let Some(mut envelope) = rx.recv().await {
6370                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6371                    vec![Ok(bytes::Bytes::from("audio data"))];
6372                let stream = Box::pin(stream::iter(chunks));
6373                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
6374                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6375                    metadata: StreamMetadata {
6376                        size_hint: None,
6377                        content_type: Some("audio/mpeg".to_string()),
6378                        origin: None,
6379                    },
6380                });
6381                if let Some(reply_tx) = envelope.reply_tx {
6382                    let _ = reply_tx.send(Ok(envelope.exchange));
6383                }
6384            }
6385        });
6386
6387        let resp = http_result.unwrap();
6388        assert_eq!(resp.status().as_u16(), 200);
6389        let ct = resp
6390            .headers()
6391            .get("content-type")
6392            .expect("Content-Type header should be present");
6393        assert_eq!(ct, "audio/mpeg");
6394        let body = resp.text().await.unwrap();
6395        assert_eq!(body, "audio data");
6396
6397        token.cancel();
6398    }
6399
6400    #[tokio::test]
6401    async fn test_user_content_type_overrides_inferred() {
6402        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
6403
6404        let client = reqwest::Client::new();
6405        let send_fut = client
6406            .get(format!("http://127.0.0.1:{port}/override-ct"))
6407            .send();
6408
6409        let (http_result, _) = tokio::join!(send_fut, async {
6410            if let Some(mut envelope) = rx.recv().await {
6411                envelope.exchange.input.body =
6412                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
6413                envelope.exchange.input.set_header(
6414                    "Content-Type",
6415                    serde_json::Value::String("text/html".to_string()),
6416                );
6417                if let Some(reply_tx) = envelope.reply_tx {
6418                    let _ = reply_tx.send(Ok(envelope.exchange));
6419                }
6420            }
6421        });
6422
6423        let resp = http_result.unwrap();
6424        assert_eq!(resp.status().as_u16(), 200);
6425        let ct = resp
6426            .headers()
6427            .get("content-type")
6428            .expect("Content-Type header should be present");
6429        assert_eq!(
6430            ct, "text/html",
6431            "User-set Content-Type should take precedence over inferred type"
6432        );
6433
6434        token.cancel();
6435    }
6436
6437    #[tokio::test]
6438    async fn test_user_content_type_with_bytes_body() {
6439        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
6440
6441        let client = reqwest::Client::new();
6442        let send_fut = client
6443            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
6444            .send();
6445
6446        let (http_result, _) = tokio::join!(send_fut, async {
6447            if let Some(mut envelope) = rx.recv().await {
6448                envelope.exchange.input.body =
6449                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
6450                envelope.exchange.input.set_header(
6451                    "Content-Type",
6452                    serde_json::Value::String("application/json".to_string()),
6453                );
6454                if let Some(reply_tx) = envelope.reply_tx {
6455                    let _ = reply_tx.send(Ok(envelope.exchange));
6456                }
6457            }
6458        });
6459
6460        let resp = http_result.unwrap();
6461        assert_eq!(resp.status().as_u16(), 200);
6462        let ct = resp
6463            .headers()
6464            .get("content-type")
6465            .expect("Content-Type header should be present for Bytes body with user header");
6466        assert_eq!(
6467            ct, "application/json",
6468            "User Content-Type should be sent for Bytes body"
6469        );
6470
6471        token.cancel();
6472    }
6473
6474    // -----------------------------------------------------------------------
6475    // Server monitor tests (GRL-005)
6476    // -----------------------------------------------------------------------
6477
6478    #[tokio::test]
6479    async fn monitor_task_silent_on_clean_exit() {
6480        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
6481        // Clean exit should complete without panicking or logging errors
6482        monitor_axum_task(
6483            handle,
6484            "127.0.0.1:0".to_string(),
6485            noop_rt(),
6486            "test-monitor".into(),
6487        )
6488        .await;
6489    }
6490
6491    #[tokio::test]
6492    async fn monitor_task_handles_panicked_task() {
6493        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
6494            panic!("simulated server crash");
6495        });
6496        // Should complete without panicking even though the inner task panicked
6497        monitor_axum_task(
6498            handle,
6499            "127.0.0.1:9999".to_string(),
6500            noop_rt(),
6501            "test-monitor".into(),
6502        )
6503        .await;
6504    }
6505
6506    // -----------------------------------------------------------------------
6507    // Credential redaction tests
6508    // -----------------------------------------------------------------------
6509
6510    #[test]
6511    fn http_auth_basic_debug_redacts_password() {
6512        let auth = HttpAuth::Basic {
6513            username: "admin".to_string(),
6514            password: "hunter2".to_string(),
6515        };
6516        let debug = format!("{:?}", auth);
6517        assert!(
6518            !debug.contains("hunter2"),
6519            "password must be redacted: {debug}"
6520        );
6521        assert!(debug.contains("admin"), "username should appear: {debug}");
6522    }
6523
6524    #[test]
6525    fn http_auth_bearer_debug_redacts_token() {
6526        let auth = HttpAuth::Bearer {
6527            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
6528        };
6529        let debug = format!("{:?}", auth);
6530        assert!(
6531            !debug.contains("eyJhbGci"),
6532            "token must be redacted: {debug}"
6533        );
6534    }
6535
6536    #[test]
6537    fn http_auth_none_debug_shows_variant() {
6538        let debug = format!("{:?}", HttpAuth::None);
6539        assert!(
6540            debug.contains("None"),
6541            "None variant should appear: {debug}"
6542        );
6543    }
6544
6545    #[test]
6546    fn http_endpoint_config_debug_redacts_auth_credentials() {
6547        let config = HttpEndpointConfig::from_uri(
6548            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
6549        )
6550        .unwrap();
6551        let debug = format!("{:?}", config);
6552        assert!(
6553            !debug.contains("secret123"),
6554            "password must be redacted in HttpEndpointConfig debug: {debug}"
6555        );
6556    }
6557
6558    // -----------------------------------------------------------------------
6559    // Static file serving tests (Task 5)
6560    // -----------------------------------------------------------------------
6561
6562    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
6563    use tower_http::services::ServeDir;
6564
6565    fn make_test_registry() -> HttpRouteRegistry {
6566        HttpRouteRegistry::new()
6567    }
6568
6569    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
6570        AppState {
6571            registry,
6572            max_request_body: 2 * 1024 * 1024,
6573            max_response_body: 10 * 1024 * 1024,
6574            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
6575        }
6576    }
6577
6578    #[allow(clippy::await_holding_lock)]
6579    #[tokio::test]
6580    async fn test_static_file_serving_serves_file_contents() {
6581        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6582        ServerRegistry::reset();
6583
6584        // Create temp dir with test files
6585        let temp_dir =
6586            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
6587        std::fs::create_dir_all(&temp_dir).unwrap();
6588        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
6589        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
6590
6591        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6592
6593        let registry = make_test_registry();
6594        let serve_dir = ServeDir::new(&canonical_dir)
6595            .precompressed_gzip()
6596            .precompressed_br()
6597            .append_index_html_on_directories(true);
6598
6599        let mount = StaticMount {
6600            mount_path: "/".to_string(),
6601            mode: MountMode::Static,
6602            dir: canonical_dir.clone(),
6603            cache_control: "public, max-age=3600".to_string(),
6604            error_pages: std::collections::HashMap::new(),
6605            serve_dir,
6606        };
6607        registry.register_static_mount(mount).await.unwrap();
6608
6609        let state = make_test_state(registry);
6610
6611        // Test serving hello.txt
6612        let req = Request::builder()
6613            .uri("/hello.txt")
6614            .body(AxumBody::empty())
6615            .unwrap();
6616        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
6617        assert_eq!(resp.status(), StatusCode::OK);
6618        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6619            .await
6620            .unwrap();
6621        assert_eq!(&body[..], b"Hello, static world!");
6622
6623        // Test serving style.css
6624        let req = Request::builder()
6625            .uri("/style.css")
6626            .body(AxumBody::empty())
6627            .unwrap();
6628        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6629        assert_eq!(resp.status(), StatusCode::OK);
6630        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6631            .await
6632            .unwrap();
6633        assert_eq!(&body[..], b"body { color: red; }");
6634
6635        // Test 404 for non-existent file
6636        let req = Request::builder()
6637            .uri("/missing.txt")
6638            .body(AxumBody::empty())
6639            .unwrap();
6640        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
6641        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6642
6643        // Cleanup
6644        std::fs::remove_dir_all(&temp_dir).ok();
6645    }
6646
6647    #[allow(clippy::await_holding_lock)]
6648    #[tokio::test]
6649    async fn test_spa_fallback_serves_index_for_unknown_paths() {
6650        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6651        ServerRegistry::reset();
6652
6653        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
6654        std::fs::create_dir_all(&temp_dir).unwrap();
6655        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
6656        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
6657
6658        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6659
6660        let registry = make_test_registry();
6661        let serve_dir = ServeDir::new(&canonical_dir)
6662            .precompressed_gzip()
6663            .precompressed_br()
6664            .append_index_html_on_directories(true);
6665
6666        let mount = StaticMount {
6667            mount_path: "/".to_string(),
6668            mode: MountMode::Spa,
6669            dir: canonical_dir.clone(),
6670            cache_control: "public, max-age=0".to_string(),
6671            error_pages: std::collections::HashMap::new(),
6672            serve_dir,
6673        };
6674        // Register as SPA mount
6675        registry.register_static_mount(mount).await.unwrap();
6676
6677        let state = make_test_state(registry);
6678
6679        // SPA fallback: GET /dashboard with Accept: text/html → index.html
6680        let req = Request::builder()
6681            .method("GET")
6682            .uri("/dashboard")
6683            .header("Accept", "text/html")
6684            .body(AxumBody::empty())
6685            .unwrap();
6686        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
6687        assert_eq!(resp.status(), StatusCode::OK);
6688        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6689            .await
6690            .unwrap();
6691        assert_eq!(&body[..], b"<h1>SPA App</h1>");
6692
6693        // Static file still works: GET /app.js
6694        let req = Request::builder()
6695            .method("GET")
6696            .uri("/app.js")
6697            .body(AxumBody::empty())
6698            .unwrap();
6699        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
6700        assert_eq!(resp.status(), StatusCode::OK);
6701        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6702            .await
6703            .unwrap();
6704        assert_eq!(&body[..], b"console.log('app')");
6705
6706        // No SPA fallback for JSON accept → 404
6707        let req = Request::builder()
6708            .method("GET")
6709            .uri("/api/data")
6710            .header("Accept", "application/json")
6711            .body(AxumBody::empty())
6712            .unwrap();
6713        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
6714        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6715
6716        // No SPA fallback for file extensions → 404
6717        let req = Request::builder()
6718            .method("GET")
6719            .uri("/style.css")
6720            .header("Accept", "text/html")
6721            .body(AxumBody::empty())
6722            .unwrap();
6723        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6724        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6725
6726        // Cleanup
6727        std::fs::remove_dir_all(&temp_dir).ok();
6728    }
6729
6730    // Regression for rc-zoai: a conditional GET (If-None-Match / If-Modified-Since)
6731    // whose validator matches MUST return 304 Not Modified, not 404. The bug was
6732    // dispatch_static's L92 gate `if resp.status().is_success()` discarding
6733    // ServeDir's legitimate 304 and falling through to the generic 404. The fix
6734    // adds `|| resp.status() == StatusCode::NOT_MODIFIED` to that gate (and the
6735    // matching gate in serve_via_serve_dir so the 304 keeps its Cache-Control).
6736    #[allow(clippy::await_holding_lock)]
6737    async fn run_conditional_get_returns_304(mode: MountMode) {
6738        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6739        ServerRegistry::reset();
6740
6741        let temp_dir = std::env::temp_dir().join(format!(
6742            "http_cond_get_{}_{}",
6743            if mode == MountMode::Spa {
6744                "spa"
6745            } else {
6746                "static"
6747            },
6748            std::process::id()
6749        ));
6750        std::fs::create_dir_all(&temp_dir).unwrap();
6751        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
6752
6753        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6754
6755        let registry = make_test_registry();
6756        let serve_dir = ServeDir::new(&canonical_dir)
6757            .precompressed_gzip()
6758            .precompressed_br()
6759            .append_index_html_on_directories(true);
6760
6761        let mount = StaticMount {
6762            mount_path: "/".to_string(),
6763            mode,
6764            dir: canonical_dir.clone(),
6765            cache_control: "public, max-age=3600".to_string(),
6766            error_pages: std::collections::HashMap::new(),
6767            serve_dir,
6768        };
6769        registry.register_static_mount(mount).await.unwrap();
6770
6771        let state = make_test_state(registry);
6772
6773        // 1st request: normal GET → 200, capture validators.
6774        let req = Request::builder()
6775            .method("GET")
6776            .uri("/index.html")
6777            .body(AxumBody::empty())
6778            .unwrap();
6779        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6780        assert_eq!(
6781            resp.status(),
6782            StatusCode::OK,
6783            "first GET should return 200, got {}",
6784            resp.status()
6785        );
6786        // Cache-Control must be attached on 200 (sanity for serve_via_serve_dir).
6787        assert!(
6788            resp.headers().contains_key(http::header::CACHE_CONTROL),
6789            "200 response missing Cache-Control"
6790        );
6791        let etag = resp
6792            .headers()
6793            .get(http::header::ETAG)
6794            .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
6795            .clone();
6796        let last_modified = resp
6797            .headers()
6798            .get(http::header::LAST_MODIFIED)
6799            .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
6800            .clone();
6801        // Consume the body so the response is fully drained.
6802        let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
6803            .await
6804            .unwrap();
6805
6806        // 2nd request: If-None-Match with the captured ETag → 304.
6807        // Unconditional: ETag presence is required (asserted above) so this
6808        // sub-test cannot silently skip on a ServeDir etag_method change.
6809        let req = Request::builder()
6810            .method("GET")
6811            .uri("/index.html")
6812            .header(http::header::IF_NONE_MATCH, etag.clone())
6813            .body(AxumBody::empty())
6814            .unwrap();
6815        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6816        assert_eq!(
6817            resp.status(),
6818            StatusCode::NOT_MODIFIED,
6819            "If-None-Match with matching ETag should return 304, got {}",
6820            resp.status()
6821        );
6822        // RFC 7232 §4.1: 304 SHOULD include Cache-Control (the serve_via_serve_dir fix).
6823        assert!(
6824            resp.headers().contains_key(http::header::CACHE_CONTROL),
6825            "304 (If-None-Match) missing Cache-Control"
6826        );
6827        // RFC 7232 §4.1: 304 SHOULD carry the validators forward. Assert the
6828        // response parts rebuild in serve_via_serve_dir preserves them.
6829        assert_eq!(
6830            resp.headers().get(http::header::ETAG),
6831            Some(&etag),
6832            "304 (If-None-Match) must echo the ETag validator"
6833        );
6834        assert_eq!(
6835            resp.headers().get(http::header::LAST_MODIFIED),
6836            Some(&last_modified),
6837            "304 (If-None-Match) must carry Last-Modified"
6838        );
6839
6840        // 3rd request: If-Modified-Since with the captured Last-Modified → 304.
6841        let req = Request::builder()
6842            .method("GET")
6843            .uri("/index.html")
6844            .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
6845            .body(AxumBody::empty())
6846            .unwrap();
6847        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6848        assert_eq!(
6849            resp.status(),
6850            StatusCode::NOT_MODIFIED,
6851            "If-Modified-Since with matching timestamp should return 304, got {}",
6852            resp.status()
6853        );
6854        assert!(
6855            resp.headers().contains_key(http::header::CACHE_CONTROL),
6856            "304 (If-Modified-Since) missing Cache-Control"
6857        );
6858        assert_eq!(
6859            resp.headers().get(http::header::ETAG),
6860            Some(&etag),
6861            "304 (If-Modified-Since) must carry the ETag validator"
6862        );
6863        assert_eq!(
6864            resp.headers().get(http::header::LAST_MODIFIED),
6865            Some(&last_modified),
6866            "304 (If-Modified-Since) must echo Last-Modified"
6867        );
6868
6869        // Negative control: a PAST If-Modified-Since (before the file's mtime)
6870        // MUST return 200 — proving the 304 path is validator-aware, not a
6871        // blanket "always 304" regression. A future date would correctly yield
6872        // 304 since the file's mtime precedes it; that is RFC-correct 304
6873        // behaviour, not a negative control.
6874        let req = Request::builder()
6875            .method("GET")
6876            .uri("/index.html")
6877            .header(
6878                http::header::IF_MODIFIED_SINCE,
6879                "Wed, 21 Oct 2000 07:28:00 GMT",
6880            )
6881            .body(AxumBody::empty())
6882            .unwrap();
6883        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6884        assert_eq!(
6885            resp.status(),
6886            StatusCode::OK,
6887            "past If-Modified-Since should return 200 (file modified after it), got {}",
6888            resp.status()
6889        );
6890
6891        // Cleanup
6892        std::fs::remove_dir_all(&temp_dir).ok();
6893    }
6894
6895    #[tokio::test]
6896    async fn test_conditional_get_returns_304_static_mode() {
6897        run_conditional_get_returns_304(MountMode::Static).await;
6898    }
6899
6900    #[tokio::test]
6901    async fn test_conditional_get_returns_304_spa_mode() {
6902        run_conditional_get_returns_304(MountMode::Spa).await;
6903    }
6904
6905    #[allow(clippy::await_holding_lock)]
6906    #[tokio::test]
6907    async fn test_error_page_mapping_serves_custom_404() {
6908        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6909        ServerRegistry::reset();
6910
6911        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
6912        let errors_dir = temp_dir.join("errors");
6913        std::fs::create_dir_all(&errors_dir).unwrap();
6914        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
6915        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
6916
6917        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6918        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
6919
6920        let registry = make_test_registry();
6921        let serve_dir = ServeDir::new(&canonical_dir)
6922            .precompressed_gzip()
6923            .precompressed_br()
6924            .append_index_html_on_directories(true);
6925
6926        let mut error_pages = std::collections::HashMap::new();
6927        error_pages.insert(404, canonical_404);
6928
6929        let mount = StaticMount {
6930            mount_path: "/".to_string(),
6931            mode: MountMode::Static,
6932            dir: canonical_dir.clone(),
6933            cache_control: "public, max-age=0".to_string(),
6934            error_pages,
6935            serve_dir,
6936        };
6937        registry.register_static_mount(mount).await.unwrap();
6938
6939        let state = make_test_state(registry);
6940
6941        // Request non-existent file → custom 404 page
6942        let req = Request::builder()
6943            .method("GET")
6944            .uri("/missing.html")
6945            .body(AxumBody::empty())
6946            .unwrap();
6947        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
6948        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6949        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6950            .await
6951            .unwrap();
6952        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
6953
6954        // Existing file still works
6955        let req = Request::builder()
6956            .method("GET")
6957            .uri("/index.html")
6958            .body(AxumBody::empty())
6959            .unwrap();
6960        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6961        assert_eq!(resp.status(), StatusCode::OK);
6962        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6963            .await
6964            .unwrap();
6965        assert_eq!(&body[..], b"<h1>Home</h1>");
6966
6967        // Cleanup
6968        std::fs::remove_dir_all(&temp_dir).ok();
6969    }
6970
6971    #[tokio::test]
6972    async fn http_consumer_returns_body_and_code_on_stop() {
6973        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
6974        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
6975        use tower::ServiceExt;
6976
6977        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
6978        let set_body_step = CompiledStep::Process {
6979            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
6980                ex.input.body = Body::Text("nope".into());
6981                Box::pin(async move { Ok(ex) })
6982            }),
6983            body_contract: None,
6984            lifecycle: None,
6985        };
6986        let set_status_step = CompiledStep::Process {
6987            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
6988                ex.input.set_header(
6989                    "CamelHttpResponseCode",
6990                    serde_json::Value::Number(409.into()),
6991                );
6992                Box::pin(async move { Ok(ex) })
6993            }),
6994            body_contract: None,
6995            lifecycle: None,
6996        };
6997        let pipeline = compose_pipeline_with_handler(
6998            vec![set_body_step, set_status_step, CompiledStep::Stop],
6999            None,
7000            PipelineRuntimeCtx::compile_time(),
7001        );
7002
7003        let ex = Exchange::new(Message::default());
7004        let result = pipeline.oneshot(ex).await;
7005        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
7006        let returned = result.unwrap();
7007        assert_eq!(returned.input.body.as_text(), Some("nope"));
7008        assert_eq!(
7009            returned
7010                .input
7011                .header("CamelHttpResponseCode")
7012                .and_then(|v| v.as_u64()),
7013            Some(409)
7014        );
7015    }
7016
7017    #[tokio::test]
7018    async fn http_consumer_returns_200_when_body_empty_on_stop() {
7019        // After ADR-0024: Stop with no body + no status header produces 200 (same as
7020        // a normal completion with no body). The 204 default is gone — users who
7021        // want 204 set CamelHttpResponseCode=204 explicitly.
7022        //
7023        // This test stays at the pipeline level (consistent with the test above).
7024        // E2E coverage of the full HTTP dispatch path is in
7025        // crates/camel-test/tests/integration_test.rs.
7026        use camel_api::{Exchange, Message};
7027        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
7028        use tower::ServiceExt;
7029
7030        let pipeline = compose_pipeline_with_handler(
7031            vec![CompiledStep::Stop],
7032            None,
7033            PipelineRuntimeCtx::compile_time(),
7034        );
7035        let ex = Exchange::new(Message::default());
7036        let result = pipeline.oneshot(ex).await;
7037        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
7038        // Body is default (empty); no CamelHttpResponseCode header was set.
7039        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
7040    }
7041
7042    // -----------------------------------------------------------------------
7043    // Task 5: Method-aware REST dispatch tests
7044    // -----------------------------------------------------------------------
7045
7046    /// Spins up an axum server on a free port with a fresh registry.
7047    /// Returns the port plus the registry so the caller can register
7048    /// REST endpoints directly.
7049    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
7050        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7051        let port = listener.local_addr().unwrap().port();
7052        let registry = HttpRouteRegistry::new();
7053        tokio::spawn(run_axum_server(
7054            listener,
7055            registry.clone(),
7056            2 * 1024 * 1024,
7057            10 * 1024 * 1024,
7058            Arc::new(tokio::sync::Semaphore::new(1024)),
7059            test_rt(),
7060            "test-route".into(),
7061        ));
7062        // Give the server a moment to start accepting.
7063        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7064        (port, registry)
7065    }
7066
7067    /// Helper for REST integration tests: spawns a responder task that
7068    /// reads from `rx`, writes a fixed `(status, body)` back via the
7069    /// envelope's reply channel, and returns once the test request is
7070    /// satisfied.
7071    fn spawn_responder(
7072        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
7073        status: u16,
7074        body: String,
7075    ) -> tokio::task::JoinHandle<()> {
7076        tokio::spawn(async move {
7077            if let Some(envelope) = rx.recv().await {
7078                let _ = envelope.reply_tx.send(HttpReply {
7079                    status,
7080                    headers: vec![],
7081                    body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
7082                });
7083            }
7084        })
7085    }
7086
7087    #[tokio::test]
7088    async fn method_aware_dispatch_same_path_different_verbs() {
7089        let (port, registry) = spawn_test_server().await;
7090
7091        // Register two REST endpoints on the same path with different
7092        // methods. This is the core scenario REST DSL needs to support:
7093        // GET /users (list) and POST /users (create) must not overwrite
7094        // each other.
7095        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7096        registry
7097            .register_rest_endpoint(
7098                "GET".into(),
7099                vec![PathSegment::Literal("users".into())],
7100                get_tx,
7101            )
7102            .await;
7103
7104        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7105        registry
7106            .register_rest_endpoint(
7107                "POST".into(),
7108                vec![PathSegment::Literal("users".into())],
7109                post_tx,
7110            )
7111            .await;
7112
7113        let get_handle = spawn_responder(get_rx, 200, "list".into());
7114        let post_handle = spawn_responder(post_rx, 201, "create".into());
7115
7116        let client = reqwest::Client::new();
7117
7118        // GET /users → list route
7119        let resp = client
7120            .get(format!("http://127.0.0.1:{port}/users"))
7121            .send()
7122            .await
7123            .unwrap();
7124        assert_eq!(resp.status().as_u16(), 200);
7125        let body = resp.text().await.unwrap();
7126        assert_eq!(body, "list");
7127
7128        // POST /users → create route
7129        let resp = client
7130            .post(format!("http://127.0.0.1:{port}/users"))
7131            .send()
7132            .await
7133            .unwrap();
7134        assert_eq!(resp.status().as_u16(), 201);
7135        let body = resp.text().await.unwrap();
7136        assert_eq!(body, "create");
7137
7138        let _ = tokio::join!(get_handle, post_handle);
7139    }
7140
7141    #[tokio::test]
7142    async fn method_aware_dispatch_templated_path_extracts_params() {
7143        let (port, registry) = spawn_test_server().await;
7144
7145        // Register GET /users/{id} as a templated endpoint. The
7146        // dispatcher should match `/users/42` against the template and
7147        // attach `id=42` to the envelope's path_params.
7148        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7149        registry
7150            .register_rest_endpoint(
7151                "GET".into(),
7152                vec![
7153                    PathSegment::Literal("users".into()),
7154                    PathSegment::Param("id".into()),
7155                ],
7156                tx,
7157            )
7158            .await;
7159
7160        // Spawn a responder that echoes the captured id back in the body
7161        // so the test can verify the param was set.
7162        let handle = tokio::spawn(async move {
7163            if let Some(envelope) = rx.recv().await {
7164                let id = envelope.path_params.get("id").cloned().unwrap_or_default();
7165                let _ = envelope.reply_tx.send(HttpReply {
7166                    status: 200,
7167                    headers: vec![],
7168                    body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
7169                });
7170            }
7171        });
7172
7173        let client = reqwest::Client::new();
7174        let resp = client
7175            .get(format!("http://127.0.0.1:{port}/users/42"))
7176            .send()
7177            .await
7178            .unwrap();
7179        assert_eq!(resp.status().as_u16(), 200);
7180        let body = resp.text().await.unwrap();
7181        assert_eq!(body, "id=42");
7182
7183        let _ = handle.await;
7184    }
7185
7186    #[tokio::test]
7187    async fn method_aware_dispatch_unmatched_method_falls_through() {
7188        // If no REST endpoint matches the method, dispatch must fall
7189        // through to the legacy api_routes lookup or static mounts. With
7190        // nothing else registered, the request gets 404 from static
7191        // dispatch.
7192        let (port, _registry) = spawn_test_server().await;
7193
7194        // Register only GET /users; a DELETE /users request has no match.
7195        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7196        _registry
7197            .register_rest_endpoint(
7198                "GET".into(),
7199                vec![PathSegment::Literal("users".into())],
7200                get_tx,
7201            )
7202            .await;
7203
7204        // Drain the GET channel in the background so the consumer side
7205        // doesn't block (we don't expect any envelopes here).
7206        let drain = tokio::spawn(async move {
7207            let mut get_rx = get_rx;
7208            while get_rx.recv().await.is_some() {}
7209        });
7210
7211        let client = reqwest::Client::new();
7212        let resp = client
7213            .delete(format!("http://127.0.0.1:{port}/users"))
7214            .send()
7215            .await
7216            .unwrap();
7217        assert_eq!(resp.status().as_u16(), 404);
7218
7219        drop(drain);
7220    }
7221
7222    #[tokio::test]
7223    async fn regression_legacy_exact_api_route_still_works() {
7224        // A `http:` route registered without an `httpMethod=` URI param
7225        // lands in the legacy api_routes registry. The dispatcher must
7226        // still find it via exact path lookup. This guards against
7227        // regressions introduced by the new REST-aware dispatch.
7228        let (port, registry) = spawn_test_server().await;
7229
7230        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7231        registry.register_api_route("/legacy/path".into(), tx).await;
7232
7233        let handle = tokio::spawn(async move {
7234            if let Some(envelope) = rx.recv().await {
7235                let _ = envelope.reply_tx.send(HttpReply {
7236                    status: 200,
7237                    headers: vec![],
7238                    body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
7239                });
7240            }
7241        });
7242
7243        let client = reqwest::Client::new();
7244        let resp = client
7245            .get(format!("http://127.0.0.1:{port}/legacy/path"))
7246            .send()
7247            .await
7248            .unwrap();
7249        assert_eq!(resp.status().as_u16(), 200);
7250        let body = resp.text().await.unwrap();
7251        assert_eq!(body, "legacy ok");
7252
7253        let _ = handle.await;
7254    }
7255
7256    #[allow(clippy::await_holding_lock)]
7257    #[tokio::test]
7258    async fn regression_static_mount_still_works() {
7259        // Verify that static file serving still works after the
7260        // dispatch refactor. We register a temp-dir mount and request
7261        // a file from it; the static dispatcher should serve it.
7262        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7263        ServerRegistry::reset();
7264
7265        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
7266        std::fs::create_dir_all(&temp_dir).unwrap();
7267        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
7268        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7269
7270        let registry = make_test_registry();
7271        let serve_dir = ServeDir::new(&canonical_dir)
7272            .precompressed_gzip()
7273            .precompressed_br()
7274            .append_index_html_on_directories(true);
7275        let mount = StaticMount {
7276            mount_path: "/".to_string(),
7277            mode: MountMode::Static,
7278            dir: canonical_dir.clone(),
7279            cache_control: "public, max-age=3600".to_string(),
7280            error_pages: std::collections::HashMap::new(),
7281            serve_dir,
7282        };
7283        registry.register_static_mount(mount).await.unwrap();
7284
7285        let state = make_test_state(registry);
7286        let req = Request::builder()
7287            .uri("/regress.txt")
7288            .body(AxumBody::empty())
7289            .unwrap();
7290        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
7291        assert_eq!(resp.status(), StatusCode::OK);
7292        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7293            .await
7294            .unwrap();
7295        assert_eq!(&body[..], b"static works");
7296
7297        std::fs::remove_dir_all(&temp_dir).ok();
7298    }
7299
7300    // -----------------------------------------------------------------------
7301    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
7302    // templated from-URI round-trip. These exercise the real axum dispatch
7303    // path (register → HTTP request → reply) so a regression in any of the
7304    // three critical fixes surfaces as a test failure rather than a silent
7305    // production 404/500.
7306    // -----------------------------------------------------------------------
7307
7308    #[tokio::test]
7309    async fn deregister_one_method_keeps_sibling_verbs() {
7310        // Review C1: stopping the GET /users consumer must NOT tear down the
7311        // live POST /users endpoint. Register both, deregister GET only,
7312        // then verify POST still dispatches.
7313        let (port, registry) = spawn_test_server().await;
7314
7315        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7316        registry
7317            .register_rest_endpoint(
7318                "GET".into(),
7319                vec![PathSegment::Literal("users".into())],
7320                get_tx,
7321            )
7322            .await;
7323
7324        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7325        registry
7326            .register_rest_endpoint(
7327                "POST".into(),
7328                vec![PathSegment::Literal("users".into())],
7329                post_tx,
7330            )
7331            .await;
7332
7333        // Drain GET in the background (no requests expected after deregister).
7334        let drain = tokio::spawn(async move {
7335            let mut get_rx = get_rx;
7336            while get_rx.recv().await.is_some() {}
7337        });
7338
7339        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
7340        registry.unregister_rest_endpoint("GET", "/users").await;
7341        drop(drain);
7342
7343        let post_handle = spawn_responder(post_rx, 201, "create".into());
7344
7345        let client = reqwest::Client::new();
7346        // POST /users must still reach its consumer after GET was removed.
7347        let resp = client
7348            .post(format!("http://127.0.0.1:{port}/users"))
7349            .send()
7350            .await
7351            .unwrap();
7352        assert_eq!(resp.status().as_u16(), 201);
7353        assert_eq!(resp.text().await.unwrap(), "create");
7354
7355        let _ = post_handle.await;
7356    }
7357
7358    #[tokio::test]
7359    async fn dispatch_exact_legacy_beats_rest_template() {
7360        // Review C2: an exact legacy API route (`GET /api/users`, no
7361        // httpMethod) must win over a templated REST route
7362        // (`GET /api/{resource}`) for the request `/api/users`, per spec
7363        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
7364        let (port, registry) = spawn_test_server().await;
7365
7366        // Exact legacy route.
7367        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7368        registry
7369            .register_api_route("/api/users".into(), exact_tx)
7370            .await;
7371        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
7372
7373        // Templated REST route that would ALSO match /api/users.
7374        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7375        registry
7376            .register_rest_endpoint(
7377                "GET".into(),
7378                vec![
7379                    PathSegment::Literal("api".into()),
7380                    PathSegment::Param("resource".into()),
7381                ],
7382                tpl_tx,
7383            )
7384            .await;
7385        // The templated handler must NOT receive the /api/users request. If
7386        // it does, it replies "template-leak" so a future assertion could
7387        // catch it. We do NOT await this task: the exact-match branch wins
7388        // and the templated channel never receives, so awaiting would block
7389        // until the test runtime tears down.
7390        let _tpl_drain = tokio::spawn(async move {
7391            let mut tpl_rx = tpl_rx;
7392            if let Some(env) = tpl_rx.recv().await {
7393                let _ = env.reply_tx.send(HttpReply {
7394                    status: 200,
7395                    headers: vec![],
7396                    body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
7397                });
7398            }
7399        });
7400
7401        let client = reqwest::Client::new();
7402        let resp = client
7403            .get(format!("http://127.0.0.1:{port}/api/users"))
7404            .send()
7405            .await
7406            .unwrap();
7407        assert_eq!(resp.status().as_u16(), 200);
7408        // Exact-match handler answered — not the templated one.
7409        assert_eq!(resp.text().await.unwrap(), "exact");
7410
7411        let _ = exact_handle.await;
7412    }
7413
7414    #[tokio::test]
7415    async fn ambiguous_rest_templates_return_500_not_silent_404() {
7416        // Review C3: two equal-specificity templates that both match one
7417        // request are an ambiguous registration. At runtime this must
7418        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
7419        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
7420        let (port, registry) = spawn_test_server().await;
7421
7422        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7423        registry
7424            .register_rest_endpoint(
7425                "GET".into(),
7426                vec![
7427                    PathSegment::Literal("users".into()),
7428                    PathSegment::Param("id".into()),
7429                ],
7430                a_tx,
7431            )
7432            .await;
7433
7434        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7435        registry
7436            .register_rest_endpoint(
7437                "GET".into(),
7438                vec![
7439                    PathSegment::Literal("users".into()),
7440                    PathSegment::Param("name".into()),
7441                ],
7442                b_tx,
7443            )
7444            .await;
7445
7446        let client = reqwest::Client::new();
7447        let resp = client
7448            .get(format!("http://127.0.0.1:{port}/users/42"))
7449            .send()
7450            .await
7451            .unwrap();
7452        // Ambiguous → 500 (previously a silent 404).
7453        assert_eq!(resp.status().as_u16(), 500);
7454    }
7455
7456    #[test]
7457    fn from_uri_round_trips_templated_path_with_http_method() {
7458        // Review I4: a REST-lowered from-URI like
7459        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
7460        // through HttpServerConfig::from_uri, preserving the templated path
7461        // and the (uppercased) method. This is the binding the DSL lowering
7462        // emits and the consumer reads; it was previously unasserted.
7463        use crate::UriConfig;
7464        let cfg =
7465            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
7466        assert_eq!(cfg.host, "0.0.0.0");
7467        assert_eq!(cfg.port, 8080);
7468        assert_eq!(cfg.path, "/users/{id}");
7469        assert_eq!(cfg.method.as_deref(), Some("GET"));
7470
7471        // Lower-case httpMethod is uppercased (review I5).
7472        let cfg_lc =
7473            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
7474        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
7475        assert_eq!(cfg_lc.path, "/orders");
7476    }
7477
7478    // -----------------------------------------------------------------------
7479    // rc-1dk4: TypeConversionFailed → 400 Bad Request
7480    // -----------------------------------------------------------------------
7481
7482    #[test]
7483    fn type_conversion_failed_maps_to_400() {
7484        let reply = pipeline_error_to_reply(
7485            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
7486            "/api/users",
7487        );
7488        assert_eq!(reply.status, 400);
7489        // Content-Type must be application/json
7490        let ct = reply
7491            .headers
7492            .iter()
7493            .find(|(k, _)| k == "Content-Type")
7494            .map(|(_, v)| v.as_str());
7495        assert_eq!(ct, Some("application/json"));
7496        // Body must contain structured error JSON
7497        let body = match &reply.body {
7498            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7499            _ => panic!("expected bytes body"),
7500        };
7501        assert!(body.contains("\"error\""));
7502        assert!(body.contains("bad_request"));
7503        assert!(body.contains("invalid JSON at line 1"));
7504    }
7505
7506    #[test]
7507    fn other_error_still_maps_to_500() {
7508        let reply =
7509            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
7510        assert_eq!(reply.status, 500);
7511    }
7512
7513    #[test]
7514    fn unauthenticated_maps_to_401() {
7515        let reply = pipeline_error_to_reply(
7516            CamelError::Unauthenticated("no token".to_string()),
7517            "/api/users",
7518        );
7519        assert_eq!(reply.status, 401);
7520    }
7521
7522    #[test]
7523    fn unauthorized_maps_to_403() {
7524        let reply = pipeline_error_to_reply(
7525            CamelError::Unauthorized("forbidden".to_string()),
7526            "/api/users",
7527        );
7528        assert_eq!(reply.status, 403);
7529    }
7530
7531    #[test]
7532    fn validation_error_maps_to_400() {
7533        let reply = pipeline_error_to_reply(
7534            CamelError::ValidationError("body does not match schema".to_string()),
7535            "/api/users",
7536        );
7537        assert_eq!(reply.status, 400);
7538        let ct = reply
7539            .headers
7540            .iter()
7541            .find(|(k, _)| k == "Content-Type")
7542            .map(|(_, v)| v.as_str());
7543        assert_eq!(ct, Some("application/json"));
7544        let body = match &reply.body {
7545            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7546            _ => panic!("expected bytes body"),
7547        };
7548        assert!(body.contains("\"error\""));
7549        assert!(body.contains("validation_error"));
7550        assert!(body.contains("body does not match schema"));
7551    }
7552
7553    #[test]
7554    fn https_consumer_without_tls_cert_errors() {
7555        let endpoint = HttpEndpoint {
7556            uri: "https://0.0.0.0:8443/api".to_string(),
7557            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7558            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7559            client: reqwest::Client::new(),
7560            http_config: HttpConfig::default(),
7561        };
7562        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7563        let result = endpoint.create_consumer(rt);
7564        assert!(result.is_err(), "expected error for https without tls cert");
7565        if let Err(e) = result {
7566            let msg = e.to_string();
7567            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
7568        }
7569    }
7570
7571    #[test]
7572    fn http_consumer_with_tls_config_errors() {
7573        let endpoint = HttpEndpoint {
7574            uri: "http://0.0.0.0:8080/api".to_string(),
7575            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
7576            server_config: HttpServerConfig::from_uri(
7577                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
7578            )
7579            .unwrap(),
7580            client: reqwest::Client::new(),
7581            http_config: HttpConfig::default(),
7582        };
7583        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7584        let result = endpoint.create_consumer(rt);
7585        assert!(result.is_err(), "expected error for http with tls config");
7586        if let Err(e) = result {
7587            let msg = e.to_string();
7588            assert!(msg.contains("https"), "error must mention https: {msg}");
7589        }
7590    }
7591
7592    #[test]
7593    fn https_consumer_with_partial_tls_cert_only_errors() {
7594        // tlsCert without tlsKey → tls_config is None at parse time
7595        // → create_consumer sees https:// + no TLS → must error
7596        let server_config =
7597            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
7598        assert!(
7599            server_config.tls_config.is_none(),
7600            "partial tlsCert must not create ServerTlsConfig"
7601        );
7602        let endpoint = HttpEndpoint {
7603            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
7604            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
7605                .unwrap(),
7606            server_config,
7607            client: reqwest::Client::new(),
7608            http_config: HttpConfig::default(),
7609        };
7610        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7611        let result = endpoint.create_consumer(rt);
7612        assert!(
7613            result.is_err(),
7614            "must error: https:// requires both tlsCert and tlsKey"
7615        );
7616    }
7617
7618    #[test]
7619    fn load_tls_config_parses_valid_pem() {
7620        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
7621        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7622        use camel_component_api::test_support::tls;
7623        let (_, cert_pem, key_pem) = tls::gen_server_cert();
7624        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
7625        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
7626
7627        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
7628        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
7629    }
7630
7631    #[tokio::test(flavor = "multi_thread")]
7632    #[allow(clippy::await_holding_lock)]
7633    async fn consumer_tls_handshake_roundtrip() {
7634        use camel_component_api::test_support::tls;
7635        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7636
7637        // Install rustls crypto provider (aws-lc-rs)
7638        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7639
7640        // Serialize against global ServerRegistry singleton
7641        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7642
7643        // Generate CA + server cert
7644        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7645        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
7646        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
7647        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
7648
7649        // Get ephemeral port
7650        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7651        let port = probe.local_addr().unwrap().port();
7652        drop(probe);
7653
7654        ServerRegistry::reset();
7655
7656        // Create real HttpComponent + endpoint with TLS URI
7657        let component = HttpComponent::new();
7658        let endpoint_ctx = NoOpComponentContext;
7659        let uri = format!(
7660            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7661            cert_path.to_string_lossy(),
7662            key_path.to_string_lossy(),
7663        );
7664        let endpoint = component
7665            .create_endpoint(&uri, &endpoint_ctx)
7666            .expect("create TLS endpoint");
7667        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
7668
7669        // Start consumer — this calls get_or_spawn with tls_config
7670        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7671        let token = tokio_util::sync::CancellationToken::new();
7672        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
7673        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7674
7675        // Give server time to start
7676        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7677
7678        // Client with CA cert — REAL verification (no danger_accept_invalid)
7679        let ca_bytes = std::fs::read(&ca_path).unwrap();
7680        let client = reqwest::Client::builder()
7681            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
7682            .build()
7683            .unwrap();
7684
7685        let send_fut = client
7686            .post(format!("https://localhost:{port}/test"))
7687            .body("ping")
7688            .send();
7689
7690        // Handler: receive envelope, reply 200 with "pong" body
7691        let (http_result, _) = tokio::join!(send_fut, async {
7692            if let Some(mut envelope) = rx.recv().await {
7693                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7694                if let Some(reply_tx) = envelope.reply_tx {
7695                    let _ = reply_tx.send(Ok(envelope.exchange));
7696                }
7697            }
7698        });
7699
7700        let resp = http_result.expect("TLS handshake + request must succeed");
7701
7702        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
7703        let body = resp.text().await.unwrap();
7704        assert_eq!(body, "pong");
7705
7706        token.cancel();
7707    }
7708
7709    #[tokio::test(flavor = "multi_thread")]
7710    #[allow(clippy::await_holding_lock)]
7711    async fn consumer_tls_rejects_client_without_ca() {
7712        use camel_component_api::test_support::tls;
7713        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7714
7715        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7716
7717        // Serialize against global ServerRegistry singleton
7718        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7719
7720        let (_, cert_pem, key_pem) = tls::gen_server_cert();
7721        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
7722        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
7723
7724        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7725        let port = probe.local_addr().unwrap().port();
7726        drop(probe);
7727
7728        ServerRegistry::reset();
7729
7730        // Spawn TLS server via real HttpComponent path
7731        let component = HttpComponent::new();
7732        let endpoint_ctx = NoOpComponentContext;
7733        let uri = format!(
7734            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7735            cert_path.to_string_lossy(),
7736            key_path.to_string_lossy(),
7737        );
7738        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
7739        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7740        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7741        let token = tokio_util::sync::CancellationToken::new();
7742        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
7743        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7744
7745        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7746
7747        // Client WITHOUT CA cert — must fail TLS verification
7748        let client = reqwest::Client::builder().build().unwrap();
7749
7750        let result = client
7751            .get(format!("https://localhost:{port}/test"))
7752            .send()
7753            .await;
7754
7755        assert!(
7756            result.is_err(),
7757            "must reject without CA — proves real verification"
7758        );
7759
7760        token.cancel();
7761    }
7762
7763    #[test]
7764    fn server_config_partial_tls_cert_without_key() {
7765        // Parse URI with only tlsCert (no tlsKey)
7766        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
7767        // Partial params → tls_config must be None
7768        assert!(cfg.tls_config.is_none());
7769    }
7770
7771    #[test]
7772    fn endpoint_uri_options_count_parity() {
7773        // Mirror struct must stay in sync with bespoke from_components parser.
7774        assert_eq!(
7775            HttpEndpointConfig::uri_options().len(),
7776            20,
7777            "HttpEndpointUriConfig #[uri_param] count drifted from parser"
7778        );
7779    }
7780
7781    fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
7782        pairs
7783            .iter()
7784            .map(|(k, v)| {
7785                (
7786                    (*k).to_string(),
7787                    serde_json::Value::String((*v).to_string()),
7788                )
7789            })
7790            .collect()
7791    }
7792
7793    #[test]
7794    fn response_emits_cache_control_via_pragma_warning() {
7795        let headers = make_headers(&[
7796            ("Cache-Control", "public, max-age=3600"),
7797            ("Via", "1.1 myproxy"),
7798            ("Pragma", "no-cache"),
7799            ("Warning", "199 misc"),
7800        ]);
7801        let selected = select_response_headers(&headers, None, None);
7802        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
7803        for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
7804            assert!(
7805                names.contains(&expected),
7806                "{expected} should pass through to the response"
7807            );
7808        }
7809    }
7810
7811    #[test]
7812    fn response_excludes_request_only_and_server_owned() {
7813        let headers = make_headers(&[
7814            ("User-Agent", "x"),
7815            ("Accept", "*/*"),
7816            ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
7817        ]);
7818        let selected = select_response_headers(&headers, None, None);
7819        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
7820        for excluded in ["User-Agent", "Accept", "Date"] {
7821            assert!(
7822                !names.contains(&excluded),
7823                "{excluded} should NOT appear in the response"
7824            );
7825        }
7826    }
7827
7828    #[test]
7829    fn response_re_derives_content_type() {
7830        let headers = make_headers(&[("Content-Type", "text/plain")]);
7831        let selected = select_response_headers(&headers, Some("application/json".into()), None);
7832        let ct_entries: Vec<&str> = selected
7833            .iter()
7834            .filter(|(k, _)| k == "Content-Type")
7835            .map(|(_, v)| v.as_str())
7836            .collect();
7837        assert_eq!(
7838            ct_entries,
7839            ["application/json"],
7840            "exactly one Content-Type entry, re-derived from user_content_type"
7841        );
7842    }
7843
7844    #[test]
7845    fn response_excludes_camel_headers() {
7846        let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
7847        let selected = select_response_headers(&headers, None, None);
7848        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
7849        assert!(
7850            !names.contains(&"CamelHttpPath"),
7851            "Camel-namespace headers must be excluded"
7852        );
7853        assert!(
7854            names.contains(&"Cache-Control"),
7855            "Cache-Control must pass through"
7856        );
7857    }
7858
7859    // -----------------------------------------------------------------------
7860    // Bridge proxy end-to-end integration tests (Task 4.1)
7861    // Fully local + deterministic: raw TCP / in-process consumer / reqwest
7862    // to 127.0.0.1. No public CDN, no httpbin, no network egress.
7863    // -----------------------------------------------------------------------
7864
7865    /// Destination server that captures the outbound request line and the
7866    /// `Host:` header the producer actually sent on the wire. Returns
7867    /// `(host_value, request_line)` so a bridge-proxy test can assert that
7868    /// the producer derived `Host` from the destination (not the exchange)
7869    /// and honoured bridging semantics for the path.
7870    async fn start_host_capturing_destination() -> (
7871        String,
7872        Arc<std::sync::Mutex<Option<(String, String)>>>,
7873        tokio::task::JoinHandle<()>,
7874    ) {
7875        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7876        let port = listener.local_addr().unwrap().port();
7877        let url = format!("http://127.0.0.1:{port}");
7878        let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
7879            Arc::new(std::sync::Mutex::new(None));
7880        let captured_clone = Arc::clone(&captured);
7881        let handle = tokio::spawn(async move {
7882            use tokio::io::{AsyncReadExt, AsyncWriteExt};
7883            if let Ok((mut stream, _)) = listener.accept().await {
7884                let mut buf = vec![0u8; 16384];
7885                let n = stream.read(&mut buf).await.unwrap_or(0);
7886                let request = String::from_utf8_lossy(&buf[..n]).to_string();
7887                if request.contains("\r\n\r\n") {
7888                    let request_line = request.lines().next().unwrap_or("").to_string();
7889                    let host_value = request
7890                        .lines()
7891                        .find(|l| l.to_lowercase().starts_with("host:"))
7892                        .and_then(|l| l.split_once(':'))
7893                        .map(|(_, v)| v.trim().to_string())
7894                        .unwrap_or_default();
7895                    *captured_clone.lock().unwrap() = Some((host_value, request_line));
7896                }
7897                let body = r#"{"echo":"ok"}"#;
7898                let resp = format!(
7899                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
7900                    body.len(),
7901                    body
7902                );
7903                let _ = stream.write_all(resp.as_bytes()).await;
7904            }
7905        });
7906        (url, captured, handle)
7907    }
7908
7909    /// A bridging producer must derive `Host` from the destination URL and
7910    /// ignore the exchange `CamelHttpPath`, matching Apache Camel bridging
7911    /// semantics. The wire-level proof is the raw `Host:` header and request
7912    /// line captured at the destination TCP socket.
7913    #[tokio::test]
7914    async fn bridge_proxy_outbound_host_matches_destination() {
7915        use tower::ServiceExt;
7916
7917        let (url, captured, _handle) = start_host_capturing_destination().await;
7918        // The Host header reqwest derives for http://127.0.0.1:{port} is the
7919        // authority, scheme-stripped: "127.0.0.1:{port}".
7920        let expected_host = url.strip_prefix("http://").unwrap();
7921
7922        let ctx = test_producer_ctx();
7923        let component = HttpComponent::new();
7924        let endpoint_ctx = NoOpComponentContext;
7925        let endpoint = component
7926            .create_endpoint(
7927                &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
7928                &endpoint_ctx,
7929            )
7930            .unwrap();
7931        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7932
7933        // Exchange carries a stale Host and a CamelHttpPath that bridging
7934        // must drop.
7935        let mut exchange = Exchange::new(Message::default());
7936        exchange.input.set_header("Host", "localhost");
7937        exchange.input.set_header("CamelHttpPath", "/foo");
7938
7939        let result = producer.oneshot(exchange).await;
7940        assert!(result.is_ok(), "producer call failed: {:?}", result);
7941
7942        tokio::time::sleep(Duration::from_millis(100)).await;
7943        let (host_value, request_line) = captured
7944            .lock()
7945            .unwrap()
7946            .take()
7947            .expect("destination capture mutex empty — producer did not reach the destination");
7948
7949        assert_ne!(
7950            host_value, "localhost",
7951            "bridge producer must not forward the exchange Host: localhost"
7952        );
7953        assert_eq!(
7954            host_value, expected_host,
7955            "Host must be derived from the destination authority (no scheme)"
7956        );
7957        assert!(
7958            !request_line.contains("/foo"),
7959            "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
7960        );
7961    }
7962
7963    /// A response header set by the route (`Cache-Control`) must survive to
7964    /// the wire. The assertion is on the reqwest HTTP response — not an
7965    /// in-process HttpReply struct — so it proves the consumer's reply
7966    /// finaliser emitted the header over the socket.
7967    #[tokio::test]
7968    async fn bridge_proxy_route_set_response_header_survives() {
7969        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7970
7971        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7972        let port = listener.local_addr().unwrap().port();
7973        drop(listener);
7974
7975        let component = HttpComponent::new();
7976        let endpoint_ctx = NoOpComponentContext;
7977        let endpoint = component
7978            .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
7979            .unwrap();
7980        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7981
7982        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7983        let token = tokio_util::sync::CancellationToken::new();
7984        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7985
7986        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7987        tokio::time::sleep(Duration::from_millis(50)).await;
7988
7989        let client = reqwest::Client::new();
7990        let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
7991
7992        // Route sets Cache-Control on the outbound reply (exchange.input is
7993        // the message the reply finaliser reads — see select_response_headers
7994        // at the dispatch site).
7995        let (http_result, _) = tokio::join!(send_fut, async {
7996            if let Some(mut envelope) = rx.recv().await {
7997                envelope
7998                    .exchange
7999                    .input
8000                    .set_header("Cache-Control", "public, max-age=3600");
8001                if let Some(reply_tx) = envelope.reply_tx {
8002                    let _ = reply_tx.send(Ok(envelope.exchange));
8003                }
8004            }
8005        });
8006
8007        let resp = http_result.unwrap();
8008        assert_eq!(resp.status().as_u16(), 200);
8009
8010        let cache_control = resp.headers().get("cache-control");
8011        assert!(
8012            cache_control.is_some(),
8013            "Cache-Control header must survive to the wire response"
8014        );
8015        assert_eq!(
8016            cache_control.unwrap().to_str().unwrap(),
8017            "public, max-age=3600"
8018        );
8019
8020        token.cancel();
8021    }
8022
8023    // -----------------------------------------------------------------------
8024    // credential-sources task 2.3: credential values stay out of diagnostics
8025    // -----------------------------------------------------------------------
8026    //
8027    // camel-http has no request access log (design.md "Redaction sinks",
8028    // ADR-0051). The only diagnostic sink on the failed-auth path is
8029    // `pipeline_error_to_reply`, which renders the (generic) error message and
8030    // the *configured* route path — never the request URI, query string, or
8031    // extracted credential. These tests pin that redact-by-construction
8032    // contract: a sentinel credential presented in a declared source must not
8033    // appear in the reply body nor in any tracing record emitted while the
8034    // request is handled.
8035    //
8036    // Capture scope: `#[traced_test]` installs a per-crate env filter
8037    // (`camel_component_http=trace`), so records from OTHER targets
8038    // (`camel_auth`, `camel_processor`, etc.) are NOT captured here. The
8039    // redaction contract for those crates is guarded by their own tests.
8040    // Revisit this capture scope if camel-auth ever logs on the auth path.
8041    use camel_api::security_policy::{AuthorizationDecision, CredentialSource, SecurityPolicy};
8042    use camel_auth::native_auth::NativeCredentialStore;
8043    use camel_auth::{RolePolicy, StaticTokenAuthenticator, TokenAuthenticator};
8044
8045    // Sentinel credential values — test fixtures only, not real secrets.
8046    const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; // allow-secret
8047    const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; // allow-secret
8048    const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; // allow-secret
8049
8050    /// Build the exchange the consumer would build for a request envelope:
8051    /// standard Camel HTTP headers plus title-cased forwarded request headers.
8052    fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
8053        let mut msg = Message::default();
8054        msg.set_header(
8055            "CamelHttpMethod",
8056            serde_json::Value::String(envelope.method.clone()),
8057        );
8058        msg.set_header(
8059            "CamelHttpPath",
8060            serde_json::Value::String(envelope.path.clone()),
8061        );
8062        msg.set_header(
8063            "CamelHttpQuery",
8064            serde_json::Value::String(envelope.query.clone()),
8065        );
8066        for (k, v) in &envelope.headers {
8067            if let Ok(val_str) = v.to_str() {
8068                msg.set_header(
8069                    title_case_header(k.as_str()),
8070                    serde_json::Value::String(val_str.to_string()),
8071                );
8072            }
8073        }
8074        Exchange::new(msg)
8075    }
8076
8077    /// Register a route whose responder authenticates each request against a
8078    /// `RolePolicy` with the given credential sources and an empty native
8079    /// store (so every presented credential fails lookup). Mirrors the
8080    /// `SecurityPolicyLayer` decision mapping onto `CamelError`.
8081    async fn spawn_failing_auth_route(
8082        registry: &HttpRouteRegistry,
8083        path: &str,
8084        sources: Vec<CredentialSource>,
8085    ) {
8086        let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
8087            NativeCredentialStore::try_new(vec![]).unwrap(),
8088        ));
8089        let policy = Arc::new(RolePolicy::new(
8090            vec!["tiles-reader".to_string()],
8091            true,
8092            false,
8093            authenticator,
8094            sources,
8095        ));
8096        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8097        registry.register_api_route(path.to_string(), tx).await;
8098        let path_owned = path.to_string();
8099        tokio::spawn(async move {
8100            while let Some(envelope) = rx.recv().await {
8101                let mut exchange = envelope_to_exchange(&envelope);
8102                let reply_tx = envelope.reply_tx;
8103                let result: Result<(), CamelError> = match policy.evaluate(&mut exchange).await {
8104                    Ok(AuthorizationDecision::Granted { .. }) => Ok(()),
8105                    Ok(AuthorizationDecision::Denied { reason, .. }) => {
8106                        Err(CamelError::Unauthorized(format!("Access denied: {reason}")))
8107                    }
8108                    Err(e) => Err(e),
8109                    _ => Err(CamelError::Unauthorized(
8110                        "access denied by security policy".to_string(),
8111                    )),
8112                };
8113                let reply = match result {
8114                    Ok(()) => HttpReply {
8115                        status: 200,
8116                        headers: vec![],
8117                        body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
8118                    },
8119                    Err(e) => pipeline_error_to_reply(e, &path_owned),
8120                };
8121                let _ = reply_tx.send(reply);
8122            }
8123        });
8124    }
8125
8126    /// Whether any tracing record captured so far (process-wide) contains
8127    /// `needle`. `#[traced_test]` installs a global subscriber writing to a
8128    /// shared buffer, so logs from spawned request-handling tasks are included.
8129    fn captured_logs_contain(needle: &str) -> bool {
8130        let buf = tracing_test::internal::global_buf().lock().unwrap();
8131        String::from_utf8_lossy(&buf).contains(needle)
8132    }
8133
8134    #[tracing_test::traced_test]
8135    #[tokio::test]
8136    async fn error_context_redacts_query_sentinel() {
8137        let (port, registry) = spawn_test_server().await;
8138        spawn_failing_auth_route(
8139            &registry,
8140            "/secure-query",
8141            vec![CredentialSource::QueryParam {
8142                param: "token".to_string(),
8143            }],
8144        )
8145        .await;
8146
8147        let client = reqwest::Client::new();
8148        let resp = client
8149            // allow-secret: `token` is the declared query-source param name, not a credential
8150            .get(format!(
8151                "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
8152            ))
8153            .send()
8154            .await
8155            .unwrap();
8156
8157        assert_eq!(resp.status().as_u16(), 401);
8158        let body = resp.text().await.unwrap();
8159        assert_eq!(body, "Unauthorized");
8160        assert!(
8161            !body.contains(SENTINEL_QRY_42),
8162            "reply body must not contain the query credential"
8163        );
8164        assert!(
8165            !captured_logs_contain(SENTINEL_QRY_42),
8166            "no tracing record during request handling may render the query credential"
8167        );
8168        // Permanent positive control: the failed-auth warn! must be captured.
8169        // If the per-crate env filter ever stops matching, this fails loudly
8170        // instead of letting the sentinel assertions pass vacuously.
8171        assert!(
8172            captured_logs_contain("Authentication failed"),
8173            "positive control: the failed-auth warn! must be captured by the test subscriber"
8174        );
8175    }
8176
8177    #[tracing_test::traced_test]
8178    #[tokio::test]
8179    async fn error_context_redacts_cookie_sentinel() {
8180        let (port, registry) = spawn_test_server().await;
8181        spawn_failing_auth_route(
8182            &registry,
8183            "/secure-cookie",
8184            vec![CredentialSource::Cookie {
8185                name: "session".to_string(),
8186            }],
8187        )
8188        .await;
8189
8190        let client = reqwest::Client::new();
8191        let resp = client
8192            .get(format!("http://127.0.0.1:{port}/secure-cookie"))
8193            .header("Cookie", format!("session={SENTINEL_CKY_7}"))
8194            .send()
8195            .await
8196            .unwrap();
8197
8198        assert_eq!(resp.status().as_u16(), 401);
8199        let body = resp.text().await.unwrap();
8200        assert_eq!(body, "Unauthorized");
8201        assert!(
8202            !body.contains(SENTINEL_CKY_7),
8203            "reply body must not contain the cookie credential"
8204        );
8205        assert!(
8206            !captured_logs_contain(SENTINEL_CKY_7),
8207            "no tracing record during request handling may render the cookie credential"
8208        );
8209    }
8210
8211    #[tracing_test::traced_test]
8212    #[tokio::test]
8213    async fn error_reply_no_credential_value() {
8214        let (port, registry) = spawn_test_server().await;
8215        spawn_failing_auth_route(
8216            &registry,
8217            "/secure-bad",
8218            vec![CredentialSource::Cookie {
8219                name: "session".to_string(),
8220            }],
8221        )
8222        .await;
8223
8224        let client = reqwest::Client::new();
8225        let resp = client
8226            .get(format!("http://127.0.0.1:{port}/secure-bad"))
8227            .header("Cookie", format!("session={SENTINEL_BAD_1}"))
8228            .send()
8229            .await
8230            .unwrap();
8231
8232        assert_eq!(resp.status().as_u16(), 401);
8233        let body = resp.text().await.unwrap();
8234        assert_eq!(body, "Unauthorized");
8235        assert!(
8236            !body.contains(SENTINEL_BAD_1),
8237            "reply body must not contain the credential value"
8238        );
8239        assert!(
8240            !captured_logs_contain(SENTINEL_BAD_1),
8241            "error logs must not render the credential value"
8242        );
8243    }
8244}