Skip to main content

camel_component_http/
lib.rs

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