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