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, raw_query_pairs};
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(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    /// Programmatic query parameters, serialized in declaration order with
118    /// minimal RFC-3986 encoding (`%20`, never `+`). Never populated from
119    /// the endpoint URI — set by callers via config construction.
120    pub query_params: Vec<(String, String)>,
121    /// Authored query bytes from the endpoint URI, verbatim (no decode, no
122    /// re-encode, no `RAW(...)` unwrapping). `Some("")` preserves a bare
123    /// `?` marker. Sole carrier of URI-authored pairs; consumed option
124    /// keys are filtered out at serialization time.
125    pub raw_query: Option<String>,
126    pub allow_internal: bool,
127    pub blocked_hosts: Vec<String>,
128    pub max_body_size: usize,
129    pub read_timeout_ms: u64,
130    pub max_response_bytes: usize,
131    pub auth: HttpAuth,
132    pub token_provider: Option<Arc<dyn TokenProvider>>,
133    pub user_agent: Option<String>,
134    pub bridge_endpoint: bool,
135    pub connection_close: bool,
136    pub skip_request_headers: Vec<String>,
137    pub skip_response_headers: Vec<String>,
138    pub follow_redirects: bool,
139    pub max_redirects: usize,
140    /// CamelHttpUri host fence (`allowedUriHosts`): `None` when the option
141    /// is absent (override behavior unchanged); `Some` arms the fail-closed
142    /// fence. Parsed entries only — never re-serialized into the outbound
143    /// query.
144    pub allowed_uri_hosts: Option<Vec<AllowedUriHost>>,
145}
146
147/// ADR-0051 redact-by-construction, ADR-0076 strictest-wins: query bytes
148/// (authored `raw_query` and programmatic `query_params`) may carry
149/// credentials. The display-surface Debug renders the raw view
150/// blanket-masked (mirroring `redact_url_for_diagnostics`) and programmatic
151/// values masked, mirroring `UriComponents`' sensitive-value masking.
152/// `base_url` routes through the canonical
153/// [`camel_api::redact::redact_url`] (string surgery, no `url::Url`
154/// roundtrip, so authored bytes are never WHATWG-normalized): userinfo is
155/// masked in every authority window, query and fragment bytes are dropped
156/// behind their sentinels, and the result is capped at 256 bytes (rc-yvjp3
157/// converged the former byte-preserving local variant). Wire fidelity is
158/// unaffected.
159impl std::fmt::Debug for HttpEndpointConfig {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.debug_struct("HttpEndpointConfig")
162            .field("base_url", &camel_api::redact::redact_url(&self.base_url))
163            .field("http_method", &self.http_method)
164            .field(
165                "throw_exception_on_failure",
166                &self.throw_exception_on_failure,
167            )
168            .field("ok_status_code_range", &self.ok_status_code_range)
169            .field("response_timeout", &self.response_timeout)
170            .field(
171                "query_params",
172                &self
173                    .query_params
174                    .iter()
175                    .map(|(key, _)| (key, "***"))
176                    .collect::<Vec<_>>(),
177            )
178            .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
179            .field("allow_internal", &self.allow_internal)
180            .field("blocked_hosts", &self.blocked_hosts)
181            .field("max_body_size", &self.max_body_size)
182            .field("read_timeout_ms", &self.read_timeout_ms)
183            .field("max_response_bytes", &self.max_response_bytes)
184            .field("auth", &self.auth)
185            .field("token_provider", &self.token_provider)
186            .field("user_agent", &self.user_agent)
187            .field("bridge_endpoint", &self.bridge_endpoint)
188            .field("connection_close", &self.connection_close)
189            .field("skip_request_headers", &self.skip_request_headers)
190            .field("skip_response_headers", &self.skip_response_headers)
191            .field("follow_redirects", &self.follow_redirects)
192            .field("max_redirects", &self.max_redirects)
193            .field("allowed_uri_hosts", &self.allowed_uri_hosts)
194            .finish()
195    }
196}
197
198#[derive(Clone, PartialEq)]
199pub enum HttpAuth {
200    None,
201    Basic { username: String, password: String },
202    Bearer { token: String },
203}
204
205impl std::fmt::Debug for HttpAuth {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        match self {
208            HttpAuth::None => f.write_str("None"),
209            HttpAuth::Basic { username, .. } => f
210                .debug_struct("Basic")
211                .field("username", username)
212                .field("password", &"***")
213                .finish(),
214            HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
215        }
216    }
217}
218
219/// Whether `key` names a camel-http endpoint option consumed at parse time.
220///
221/// Single metadata-driven owner of OUTBOUND option filtering (ADR-0041):
222/// derived from the `#[uri_param]` metadata behind
223/// [`HttpEndpointConfig::uri_options`], so the raw query filter consumes
224/// exactly the keys the component documents — no duplicated handwritten
225/// key lists. `from_components`'s manual typed parsing stays direct and
226/// unchanged; this predicate never re-wires it.
227fn is_consumed_option(key: &str) -> bool {
228    HttpEndpointConfig::uri_options()
229        .iter()
230        .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
231}
232
233impl UriConfig for HttpEndpointConfig {
234    /// Returns "http" as the primary scheme (also accepts "https")
235    fn scheme() -> &'static str {
236        "http"
237    }
238
239    fn from_uri(uri: &str) -> Result<Self, CamelError> {
240        let parts = parse_uri(uri)?;
241        Self::from_components(parts)
242    }
243
244    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
245        // Validate scheme - accept both http and https
246        if parts.scheme != "http" && parts.scheme != "https" {
247            return Err(CamelError::InvalidUri(format!(
248                "expected scheme 'http' or 'https', got '{}'",
249                parts.scheme
250            )));
251        }
252
253        // Construct base_url from scheme + path
254        // e.g., "http://localhost:8080/api" from scheme "http" and path "//localhost:8080/api"
255        let base_url = format!("{}:{}", parts.scheme, parts.path);
256
257        let http_method = parts.params.get("httpMethod").cloned();
258
259        let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
260            Some(v) => parse_bool_param_http(v).map_err(|e| {
261                CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
262            })?,
263            None => true,
264        };
265
266        // Parse status code range from "start-end" format (e.g., "200-299")
267        let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
268            Some(v) => parse_ok_status_code_range(v)?,
269            None => (200, 299),
270        };
271
272        let response_timeout = match parts.params.get("responseTimeout") {
273            Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
274                CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
275            })?),
276            None => None,
277        };
278
279        // SSRF protection settings
280        let allow_internal = match parts.params.get("allowInternal") {
281            Some(v) => parse_bool_param_http(v).map_err(|e| {
282                CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
283            })?,
284            None => false, // Default: block private IPs
285        };
286
287        // Parse comma-separated blocked hosts
288        let blocked_hosts = parts
289            .params
290            .get("blockedHosts")
291            .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
292            .unwrap_or_default();
293
294        let max_body_size = match parts.params.get("maxBodySize") {
295            Some(v) => v.parse::<usize>().map_err(|e| {
296                CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
297            })?,
298            None => 10 * 1024 * 1024, // Default: 10MB
299        };
300
301        let read_timeout_ms = match parts.params.get("readTimeout") {
302            Some(v) => v.parse::<u64>().map_err(|e| {
303                CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
304            })?,
305            None => 30_000, // Default: 30s
306        };
307
308        let max_response_bytes = match parts.params.get("maxResponseBytes") {
309            Some(v) => v.parse::<usize>().map_err(|e| {
310                CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
311            })?,
312            None => 10 * 1024 * 1024, // Default: 10MB
313        };
314
315        let auth = parse_auth_from_params(&parts.params)?;
316
317        let user_agent = parts.params.get("userAgent").cloned();
318
319        if parts.params.contains_key("cookieHandling") {
320            return Err(CamelError::InvalidUri(
321                "cookieHandling is not supported".into(),
322            ));
323        }
324
325        let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
326            Some(v) => parse_bool_param_http(v).map_err(|e| {
327                CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
328            })?,
329            None => false,
330        };
331
332        let connection_close = match parts.params.get("connectionClose") {
333            Some(v) => parse_bool_param_http(v).map_err(|e| {
334                CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
335            })?,
336            None => false,
337        };
338
339        let skip_request_headers = parts
340            .params
341            .get("skipRequestHeaders")
342            .map(|v| {
343                v.split(',')
344                    .map(str::trim)
345                    .filter(|s| !s.is_empty())
346                    .map(|s| s.to_ascii_lowercase())
347                    .collect::<Vec<_>>()
348            })
349            .unwrap_or_default();
350
351        let skip_response_headers = parts
352            .params
353            .get("skipResponseHeaders")
354            .map(|v| {
355                v.split(',')
356                    .map(str::trim)
357                    .filter(|s| !s.is_empty())
358                    .map(|s| s.to_ascii_lowercase())
359                    .collect::<Vec<_>>()
360            })
361            .unwrap_or_default();
362
363        let follow_redirects = match parts.params.get("followRedirects") {
364            Some(v) => parse_bool_param_http(v).map_err(|e| {
365                CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
366            })?,
367            None => false,
368        };
369
370        let max_redirects = match parts.params.get("maxRedirects") {
371            Some(v) => v.parse::<usize>().map_err(|e| {
372                CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
373            })?,
374            None => 10,
375        };
376
377        // CamelHttpUri host fence: parsed eagerly so a malformed or empty
378        // allowlist fails endpoint creation (fail-closed), not resolution.
379        let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
380            Some(v) => Some(parse_allowed_uri_hosts(v)?),
381            None => None,
382        };
383
384        // Authored pairs ride raw_query verbatim (the sole carrier);
385        // query_params is programmatic-only — never auto-populated from
386        // URI leftovers. Consumed option keys are filtered at
387        // serialization time by `is_consumed_option`.
388        let raw_query = parts.raw_query.clone();
389
390        Ok(Self {
391            base_url,
392            http_method,
393            throw_exception_on_failure,
394            ok_status_code_range,
395            response_timeout,
396            query_params: Vec::new(),
397            raw_query,
398            allow_internal,
399            blocked_hosts,
400            max_body_size,
401            read_timeout_ms,
402            max_response_bytes,
403            auth,
404            token_provider: None,
405            user_agent,
406            bridge_endpoint,
407            connection_close,
408            skip_request_headers,
409            skip_response_headers,
410            follow_redirects,
411            max_redirects,
412            allowed_uri_hosts,
413        })
414    }
415}
416
417/// Private container for macro-derived `uri_options()` and `metadata()`.
418///
419/// Mirrors the URI query parameters parsed by `HttpEndpointConfig::from_components`.
420/// `HttpEndpointConfig` holds typed fields (tuples, `Duration`, `HttpAuth`,
421/// `Arc<dyn TokenProvider>`) that the derive cannot represent, so metadata
422/// derivation targets this inner type whose fields are all URI-param-compatible.
423#[derive(Debug, Clone, UriConfig)]
424#[allow(dead_code)]
425#[uri_scheme = "http"]
426#[uri_config(
427    skip_impl,
428    metadata(
429        scheme = "http",
430        description = "HTTP client and server component",
431        producer,
432        consumer,
433        streaming
434    ),
435    crate = "camel_component_api"
436)]
437struct HttpEndpointUriConfig {
438    #[allow(dead_code)]
439    _base_url: String,
440
441    #[uri_param(
442        name = "httpMethod",
443        desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
444    )]
445    http_method: Option<String>,
446
447    #[uri_param(
448        name = "throwExceptionOnFailure",
449        default = "true",
450        desc = "Throw on non-2xx status"
451    )]
452    throw_exception_on_failure: bool,
453
454    #[uri_param(
455        name = "okStatusCodeRange",
456        default = "200-299",
457        desc = "Success status code range"
458    )]
459    ok_status_code_range: String,
460
461    #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
462    response_timeout: Option<u64>,
463
464    #[uri_param(
465        name = "connectTimeout",
466        desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
467    )]
468    connect_timeout: Option<u64>,
469
470    #[uri_param(
471        name = "allowInternal",
472        default = "false",
473        desc = "Allow private/internal network destinations (SSRF)"
474    )]
475    allow_internal: bool,
476
477    #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
478    blocked_hosts: Option<String>,
479
480    #[uri_param(
481        name = "maxBodySize",
482        default = "10485760",
483        desc = "Max request/response body bytes"
484    )]
485    max_body_size: u64,
486
487    #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
488    read_timeout: Option<u64>,
489
490    #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
491    max_response_bytes: Option<u64>,
492
493    #[uri_param(
494        name = "authMethod",
495        kind = "enum:Basic,Bearer",
496        desc = "Authentication method"
497    )]
498    auth_method: Option<String>,
499
500    #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
501    auth_username: Option<String>,
502
503    #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
504    auth_password: Option<String>,
505
506    #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
507    auth_bearer_token: Option<String>,
508
509    #[uri_param(name = "userAgent", desc = "User-Agent header")]
510    user_agent: Option<String>,
511
512    #[uri_param(
513        name = "bridgeEndpoint",
514        default = "false",
515        desc = "Bridge endpoint mode"
516    )]
517    bridge_endpoint: bool,
518
519    #[uri_param(
520        name = "connectionClose",
521        default = "false",
522        desc = "Send Connection: close"
523    )]
524    connection_close: bool,
525
526    #[uri_param(
527        name = "skipRequestHeaders",
528        desc = "Comma-separated request headers to skip"
529    )]
530    skip_request_headers: Option<String>,
531
532    #[uri_param(
533        name = "skipResponseHeaders",
534        desc = "Comma-separated response headers to skip"
535    )]
536    skip_response_headers: Option<String>,
537
538    #[uri_param(
539        name = "followRedirects",
540        default = "false",
541        desc = "Follow HTTP redirects"
542    )]
543    follow_redirects: bool,
544
545    #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
546    max_redirects: u64,
547
548    #[uri_param(
549        name = "allowedUriHosts",
550        desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
551    )]
552    allowed_uri_hosts: Option<String>,
553}
554
555impl HttpEndpointConfig {
556    /// Component metadata for the http/https scheme, derived from the
557    /// `#[uri_param]` fields on `HttpEndpointUriConfig`.
558    pub fn metadata() -> ComponentMetadata {
559        HttpEndpointUriConfig::metadata()
560    }
561
562    /// URI option definitions, derived from `#[uri_param]` fields.
563    pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
564        HttpEndpointUriConfig::uri_options()
565    }
566}
567
568fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
569    let Some(method) = params.get("authMethod") else {
570        return Ok(HttpAuth::None);
571    };
572
573    if method.eq_ignore_ascii_case("none") {
574        return Ok(HttpAuth::None);
575    }
576
577    if method.eq_ignore_ascii_case("basic") {
578        let username = params.get("authUsername").cloned().ok_or_else(|| {
579            CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
580        })?;
581        let password = params.get("authPassword").cloned().ok_or_else(|| {
582            CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
583        })?;
584        return Ok(HttpAuth::Basic { username, password });
585    }
586
587    if method.eq_ignore_ascii_case("bearer") {
588        let token = params.get("authBearerToken").cloned().ok_or_else(|| {
589            CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
590        })?;
591        return Ok(HttpAuth::Bearer { token });
592    }
593
594    Err(CamelError::InvalidUri(format!(
595        "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
596    )))
597}
598
599fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
600    match value.to_ascii_lowercase().as_str() {
601        "true" | "1" | "yes" => Ok(true),
602        "false" | "0" | "no" => Ok(false),
603        _ => Err(CamelError::InvalidUri(format!(
604            "invalid boolean value: '{value}'"
605        ))),
606    }
607}
608
609impl HttpEndpointConfig {
610    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
611        let parts = parse_uri(uri)?;
612        let mut endpoint = Self::from_components(parts.clone())?;
613        if endpoint.response_timeout.is_none() {
614            endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
615        }
616        if !parts.params.contains_key("allowInternal") {
617            endpoint.allow_internal = config.allow_internal;
618        }
619        if !parts.params.contains_key("blockedHosts") {
620            endpoint.blocked_hosts = config.blocked_hosts.clone();
621        }
622        if !parts.params.contains_key("maxBodySize") {
623            endpoint.max_body_size = config.max_body_size;
624        }
625        if !parts.params.contains_key("readTimeout") {
626            endpoint.read_timeout_ms = config.read_timeout_ms;
627        }
628        if !parts.params.contains_key("maxResponseBytes") {
629            endpoint.max_response_bytes = config.max_response_bytes;
630        }
631        if !parts.params.contains_key("okStatusCodeRange")
632            && let Some(range) = &config.ok_status_code_range
633        {
634            endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
635        }
636        if !parts.params.contains_key("followRedirects") {
637            endpoint.follow_redirects = config.follow_redirects;
638        }
639        if !parts.params.contains_key("maxRedirects") {
640            endpoint.max_redirects = config.max_redirects.unwrap_or(10);
641        }
642
643        Ok(endpoint)
644    }
645}
646
647// ---------------------------------------------------------------------------
648// HttpServerConfig
649// ---------------------------------------------------------------------------
650
651/// Configuration for an HTTP server (consumer) endpoint.
652#[derive(Debug, Clone)]
653pub struct HttpServerConfig {
654    /// URI scheme ("http" or "https") parsed from the endpoint URI.
655    pub scheme: String,
656    /// Bind address, e.g. "0.0.0.0" or "127.0.0.1".
657    pub host: String,
658    /// TCP port to listen on.
659    pub port: u16,
660    /// URL path this consumer handles, e.g. "/orders".
661    pub path: String,
662    /// Maximum request body size in bytes.
663    pub max_request_body: usize,
664    /// Maximum response body size for materializing streams in bytes.
665    pub max_response_body: usize,
666    /// Maximum number of in-flight requests handled concurrently by this server.
667    pub max_inflight_requests: usize,
668    /// HTTP method this consumer handles (e.g. `"GET"`). When `Some`,
669    /// the consumer registers as a method-aware REST endpoint and the
670    /// path is treated as a template (e.g. `/users/{id}` is matched
671    /// against any `/users/<value>`). When `None`, the consumer
672    /// registers in the legacy path-only `api_routes` registry.
673    /// Extracted from the `httpMethod=` URI param at config build time.
674    pub method: Option<String>,
675    /// Server-side TLS config. Populated from `tlsCert`/`tlsKey` URI params.
676    /// `None` for plain HTTP servers.
677    pub tls_config: Option<crate::config::ServerTlsConfig>,
678}
679
680impl UriConfig for HttpServerConfig {
681    /// Returns "http" as the primary scheme (also accepts "https")
682    fn scheme() -> &'static str {
683        "http"
684    }
685
686    fn from_uri(uri: &str) -> Result<Self, CamelError> {
687        let parts = parse_uri(uri)?;
688        Self::from_components(parts)
689    }
690
691    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
692        // Validate scheme - accept both http and https
693        if parts.scheme != "http" && parts.scheme != "https" {
694            return Err(CamelError::InvalidUri(format!(
695                "expected scheme 'http' or 'https', got '{}'",
696                parts.scheme
697            )));
698        }
699
700        // parts.path is everything after the scheme colon, e.g. "//0.0.0.0:8080/orders"
701        // Strip leading "//"
702        let authority_and_path = parts.path.trim_start_matches('/');
703
704        // Split on the first "/" to separate "host:port" from "/path"
705        let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
706            (&authority_and_path[..idx], &authority_and_path[idx..])
707        } else {
708            (authority_and_path, "/")
709        };
710
711        let path = if path_suffix.is_empty() {
712            "/"
713        } else {
714            path_suffix
715        }
716        .to_string();
717
718        // Parse host:port from authority
719        let (host, port) = if let Some(colon) = authority.rfind(':') {
720            let port_str = &authority[colon + 1..];
721            match port_str.parse::<u16>() {
722                Ok(p) => (authority[..colon].to_string(), p),
723                Err(_) => {
724                    return Err(CamelError::InvalidUri(format!(
725                        "invalid port '{}' in authority",
726                        port_str
727                    )));
728                }
729            }
730        } else {
731            // Default port based on scheme: 443 for https, 80 for http
732            let default_port = if parts.scheme == "https" { 443 } else { 80 };
733            (authority.to_string(), default_port)
734        };
735
736        let max_request_body = parts
737            .params
738            .get("maxRequestBody")
739            .and_then(|v| v.parse::<usize>().ok())
740            .unwrap_or(2 * 1024 * 1024); // Default: 2MB
741
742        let max_response_body = parts
743            .params
744            .get("maxResponseBody")
745            .and_then(|v| v.parse::<usize>().ok())
746            .unwrap_or(10 * 1024 * 1024); // Default: 10MB
747
748        let max_inflight_requests = parts
749            .params
750            .get("maxInflightRequests")
751            .and_then(|v| v.parse::<usize>().ok())
752            .unwrap_or(1024);
753
754        // Uppercase-normalize so a hand-written `httpMethod=get` matches the
755        // uppercase method the dispatcher compares against (axum's
756        // `req.method().to_string()` yields "GET"). Without this, a
757        // lower-case `httpMethod` would never match and silently 404.
758        // Review I5.
759        let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
760
761        Ok(Self {
762            scheme: parts.scheme,
763            host,
764            port,
765            path,
766            max_request_body,
767            max_response_body,
768            max_inflight_requests,
769            method,
770            tls_config: {
771                let cert = parts.params.get("tlsCert").cloned();
772                let key = parts.params.get("tlsKey").cloned();
773                match (cert, key) {
774                    (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
775                        cert_path: c,
776                        key_path: k,
777                    }),
778                    (None, None) => None,
779                    _ => None, // partial — enforced in create_consumer, not here
780                }
781            },
782        })
783    }
784}
785
786impl HttpServerConfig {
787    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
788        let parts = parse_uri(uri)?;
789        let mut server = Self::from_components(parts.clone())?;
790        if !parts.params.contains_key("maxRequestBody") {
791            server.max_request_body = config.max_request_body;
792        }
793        if !parts.params.contains_key("maxResponseBody") {
794            // Default max_response_body is 10MB via HttpConfig::default().max_body_size.
795            server.max_response_body = config.max_body_size;
796        }
797        Ok(server)
798    }
799}
800
801// ---------------------------------------------------------------------------
802// RequestEnvelope / HttpReply
803// ---------------------------------------------------------------------------
804
805/// Body of the HTTP response: already-materialized bytes or a lazy stream.
806///
807/// **Internal plumbing** — subject to change without notice.
808pub enum HttpReplyBody {
809    Bytes(bytes::Bytes),
810    Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
811}
812
813/// An inbound HTTP request sent from the Axum dispatch handler to an
814/// `HttpConsumer` receive loop.
815///
816/// **Internal plumbing** — subject to change without notice.
817pub struct RequestEnvelope {
818    pub method: String,
819    pub path: String,
820    pub query: String,
821    pub headers: http::HeaderMap,
822    pub body: StreamBody,
823    /// Path parameters extracted from a REST template match, e.g.
824    /// `id=42` for a request to `/users/42` matched against
825    /// `/users/{id}`. Empty for non-REST requests or for literal
826    /// template matches. The consumer turns these into
827    /// `CamelHttpPath_<param>` headers on the Exchange (expert guidance E2).
828    pub path_params: std::collections::HashMap<String, String>,
829    pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
830}
831
832/// The HTTP response that `HttpConsumer` sends back to the Axum handler.
833///
834/// **Internal plumbing** — subject to change without notice.
835pub struct HttpReply {
836    pub status: u16,
837    pub headers: Vec<(String, String)>,
838    pub body: HttpReplyBody,
839}
840
841// ---------------------------------------------------------------------------
842// HttpRouteRegistry / ServerRegistry
843// ---------------------------------------------------------------------------
844
845type ServerKey = (String, u16);
846
847/// Handle to a running Axum server on one interface/port.
848struct ServerHandle {
849    registry: HttpRouteRegistry,
850    /// Actual local address of the served listening socket (differs from the
851    /// configured `host:port` when spawning from a staged/pre-bound listener).
852    bound_addr: std::net::SocketAddr,
853    max_request_body: usize,
854    max_response_body: usize,
855    max_inflight_requests: usize,
856    is_tls: bool,
857    tls_cert_path: Option<String>,
858    tls_key_path: Option<String>,
859    /// JoinHandle for the monitor_axum_task wrapper. `is_finished()` is the
860    /// dead-server eviction signal in `get_or_spawn`.
861    monitor_task: tokio::task::JoinHandle<()>,
862    /// Abort handle for the Axum server task itself. The JoinHandle is
863    /// consumed by `monitor_axum_task`; this survives on the handle so
864    /// crashed-server tests (and future ops tooling) can deterministically
865    /// kill the shared transport to exercise the death path.
866    /// Test-only today — no production reader yet (rc-szmob).
867    #[allow(dead_code)]
868    server_abort: tokio::task::AbortHandle,
869    // Retained so the reload handler (Task 7) can call reload_from_config()
870    // to hot-swap certs without restarting the server.
871    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
872    tls_source: Option<ServerTlsSource>,
873}
874
875/// Internal registry state: live server entries plus pre-bound listeners
876/// staged for consumption by the next spawn on the same key.
877#[derive(Default)]
878struct RegistryState {
879    entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
880    staged: HashMap<ServerKey, tokio::net::TcpListener>,
881}
882
883/// Process-global registry mapping (host, port) → running Axum server handle.
884pub struct ServerRegistry {
885    inner: Mutex<RegistryState>,
886}
887
888impl ServerRegistry {
889    /// Returns the global singleton.
890    pub fn global() -> &'static Self {
891        static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
892        INSTANCE.get_or_init(|| ServerRegistry {
893            inner: Mutex::new(RegistryState::default()),
894        })
895    }
896
897    /// Returns route registry for `port`, spawning new Axum server if
898    /// none is running on that port yet.
899    #[allow(clippy::too_many_arguments)]
900    pub async fn get_or_spawn(
901        &'static self,
902        host: &str,
903        port: u16,
904        max_request_body: usize,
905        max_response_body: usize,
906        max_inflight_requests: usize,
907        runtime: Arc<dyn RuntimeObservability>,
908        route_id: String,
909        tls_config: Option<crate::config::ServerTlsConfig>,
910    ) -> Result<HttpRouteRegistry, CamelError> {
911        self.get_or_spawn_internal(
912            host,
913            port,
914            max_request_body,
915            max_response_body,
916            max_inflight_requests,
917            runtime,
918            route_id,
919            tls_config,
920            None,
921        )
922        .await
923    }
924
925    /// Like [`ServerRegistry::get_or_spawn`], but serves `listener` instead
926    /// of binding `host:port`. The registry key is derived from the listener's
927    /// actual local address, so callers must query that port afterwards. If an
928    /// entry for the key already holds a live server, the same compatibility
929    /// checks as `get_or_spawn` apply and the entry is reused; the passed
930    /// listener is simply dropped.
931    #[allow(clippy::too_many_arguments)]
932    pub async fn get_or_spawn_with_listener(
933        &'static self,
934        listener: tokio::net::TcpListener,
935        max_request_body: usize,
936        max_response_body: usize,
937        max_inflight_requests: usize,
938        runtime: Arc<dyn RuntimeObservability>,
939        route_id: String,
940        tls_config: Option<crate::config::ServerTlsConfig>,
941    ) -> Result<HttpRouteRegistry, CamelError> {
942        let addr = listener
943            .local_addr()
944            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
945        self.get_or_spawn_internal(
946            &addr.ip().to_string(),
947            addr.port(),
948            max_request_body,
949            max_response_body,
950            max_inflight_requests,
951            runtime,
952            route_id,
953            tls_config,
954            Some(listener),
955        )
956        .await
957    }
958
959    /// Stage a pre-bound listener so the next `get_or_spawn` for its
960    /// `(ip, port)` key serves this socket instead of binding a new one.
961    ///
962    /// The staged listener is consumed by exactly one spawn: the exact-key
963    /// `get_or_spawn` takes it under the registry lock, eliminating the bind
964    /// window between a port probe and server startup (itest-bound-ports).
965    pub async fn stage_listener(
966        &'static self,
967        listener: tokio::net::TcpListener,
968    ) -> Result<(), CamelError> {
969        let addr = listener
970            .local_addr()
971            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
972        let host = addr.ip().to_string();
973        use std::collections::hash_map::Entry;
974        let mut guard = self.inner.lock().map_err(|_| {
975            CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
976        })?;
977        match guard.staged.entry((host.clone(), addr.port())) {
978            Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
979                "listener already staged for {host}:{}",
980                addr.port()
981            ))),
982            Entry::Vacant(slot) => {
983                slot.insert(listener);
984                Ok(())
985            }
986        }
987    }
988
989    /// Returns the bound address of the live server entry for `(host, port)`,
990    /// if one is initialized.
991    pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
992        let guard = self.inner.lock().ok()?;
993        guard
994            .entries
995            .get(&(host.to_string(), port))
996            .and_then(|cell| cell.get())
997            .map(|handle| handle.bound_addr)
998    }
999
1000    #[allow(clippy::too_many_arguments)]
1001    async fn get_or_spawn_internal(
1002        &'static self,
1003        host: &str,
1004        port: u16,
1005        max_request_body: usize,
1006        max_response_body: usize,
1007        max_inflight_requests: usize,
1008        runtime: Arc<dyn RuntimeObservability>,
1009        route_id: String,
1010        tls_config: Option<crate::config::ServerTlsConfig>,
1011        provided: Option<tokio::net::TcpListener>,
1012    ) -> Result<HttpRouteRegistry, CamelError> {
1013        let host_owned = host.to_string();
1014        let key = (host.to_string(), port);
1015
1016        let cell = {
1017            let mut guard = self.inner.lock().map_err(|_| {
1018                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1019            })?;
1020            // Evict dead server so a fresh one can spawn (matches gRPC D-L2 pattern).
1021            // The monitor task awaits the server task, so monitor_task.is_finished()
1022            // is a reliable proxy for the server being gone (either crashed or aborted).
1023            if let Some(existing) = guard.entries.get(&key)
1024                && let Some(handle) = existing.get()
1025                && handle.monitor_task.is_finished()
1026            {
1027                // Deregister TLS reload handler so a respawned HTTPS server
1028                // doesn't reload stale cert config from the crashed handler.
1029                if handle.is_tls {
1030                    let scheme = if handle.is_tls { "https" } else { "http" };
1031                    camel_component_api::tls_source::TlsReloadRegistry::global()
1032                        .unregister(scheme, host, port);
1033                }
1034                guard.entries.remove(&key);
1035            }
1036            guard
1037                .entries
1038                .entry(key)
1039                .or_insert_with(|| Arc::new(OnceCell::new()))
1040                .clone()
1041        };
1042
1043        if let Some(existing) = cell.get()
1044            && existing.max_request_body != max_request_body
1045        {
1046            return Err(CamelError::EndpointCreationFailed(format!(
1047                "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1048                existing.max_request_body, max_request_body
1049            )));
1050        }
1051
1052        if let Some(existing) = cell.get()
1053            && existing.max_response_body != max_response_body
1054        {
1055            return Err(CamelError::EndpointCreationFailed(format!(
1056                "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1057                existing.max_response_body, max_response_body
1058            )));
1059        }
1060
1061        if let Some(existing) = cell.get()
1062            && existing.max_inflight_requests != max_inflight_requests
1063        {
1064            return Err(CamelError::EndpointCreationFailed(format!(
1065                "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1066                existing.max_inflight_requests, max_inflight_requests
1067            )));
1068        }
1069
1070        // TLS mode mismatch: plain vs TLS
1071        if let Some(existing) = cell.get()
1072            && existing.is_tls != tls_config.is_some()
1073        {
1074            return Err(CamelError::EndpointCreationFailed(format!(
1075                "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1076                existing.is_tls,
1077                tls_config.is_some()
1078            )));
1079        }
1080
1081        // TLS cert/key mismatch: different cert on same TLS port
1082        if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1083            && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1084                || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1085        {
1086            return Err(CamelError::EndpointCreationFailed(format!(
1087                "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1088            )));
1089        }
1090
1091        let handle = cell
1092            .get_or_try_init(|| {
1093                let rt = Arc::clone(&runtime);
1094                let rid = route_id.clone();
1095                let key = (host_owned.clone(), port);
1096                async move {
1097                    // Resolve the listener source inside the init body so
1098                    // exactly one caller — the init winner — consumes a
1099                    // staged listener. Resolving it before the cell init let
1100                    // a racing caller strand the staged socket in the
1101                    // loser's hands: the winner then bound the same port and
1102                    // failed with EADDRINUSE. The sync registry lock here is
1103                    // never held across an await. Occupied cells never run
1104                    // this body, so they never touch the staged map.
1105                    let source = match provided {
1106                        Some(listener) => ListenerSource::Staged(listener),
1107                        None => {
1108                            let mut guard = self.inner.lock().map_err(|_| {
1109                                CamelError::EndpointCreationFailed(
1110                                    "ServerRegistry lock poisoned".into(),
1111                                )
1112                            })?;
1113                            match guard.staged.remove(&key) {
1114                                Some(listener) => ListenerSource::Staged(listener),
1115                                // Conflict check before any entry is
1116                                // initialized so the error leaves the staged
1117                                // slot untouched.
1118                                None => {
1119                                    if let Some((staged_host, _)) = guard
1120                                        .staged
1121                                        .keys()
1122                                        .find(|(_, staged_port)| *staged_port == port)
1123                                    {
1124                                        let staged_host = staged_host.clone();
1125                                        return Err(CamelError::EndpointCreationFailed(
1126                                            format!(
1127                                                "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1128                                            ),
1129                                        ));
1130                                    }
1131                                    ListenerSource::Bind
1132                                }
1133                            }
1134                        }
1135                    };
1136                    spawn_entry(
1137                        key,
1138                        source,
1139                        max_request_body,
1140                        max_response_body,
1141                        max_inflight_requests,
1142                        rt,
1143                        rid,
1144                        tls_config,
1145                    )
1146                    .await
1147                    .and_then(|handle| {
1148                        // spawn_entry returns a freshly created Arc (refcount
1149                        // 1), so unwrapping it back into the owned handle for
1150                        // the cell always succeeds here.
1151                        Arc::try_unwrap(handle).map_err(|_| {
1152                            CamelError::EndpointCreationFailed(
1153                                "spawned server handle has dangling clones".into(),
1154                            )
1155                        })
1156                    })
1157                }
1158            })
1159            .await?;
1160
1161        Ok(handle.registry.clone())
1162    }
1163
1164    /// Unregister one consumer from a server. HTTP servers are process-lifetime:
1165    /// the server stays in the registry for potential restart. Path
1166    /// deregistration happens separately in the consumer's cleanup.
1167    pub async fn unregister(&self, host: &str, port: u16) {
1168        debug!(
1169            host = host,
1170            port = port,
1171            "consumer unregistered from HTTP server"
1172        );
1173    }
1174
1175    /// Reset the global registry — **test-only**.
1176    ///
1177    /// Clears all registered server handles so that tests can start from a clean
1178    /// state. This is intentionally `#[cfg(test)]` because the registry is a
1179    /// process-global singleton in production and resetting it would break
1180    /// running servers.
1181    #[cfg(test)]
1182    pub fn reset() {
1183        let instance = Self::global();
1184        let mut guard = instance
1185            .inner
1186            .lock()
1187            .expect("ServerRegistry lock poisoned during test reset");
1188        guard.entries.clear();
1189        guard.staged.clear();
1190    }
1191}
1192
1193/// Where a spawned server's listening socket comes from: a fresh bind on
1194/// `key`, or a listener pre-bound (staged or passed) by the caller.
1195enum ListenerSource {
1196    Bind,
1197    Staged(tokio::net::TcpListener),
1198}
1199
1200/// Create the server handle for a vacant registry entry: serve `key` via a
1201/// freshly bound or caller-provided listener. This is the OnceCell init body
1202/// of `get_or_spawn`, extracted so the legacy and staged entry points share
1203/// one spawn path.
1204#[allow(clippy::too_many_arguments)]
1205async fn spawn_entry(
1206    key: ServerKey,
1207    source: ListenerSource,
1208    max_request_body: usize,
1209    max_response_body: usize,
1210    max_inflight_requests: usize,
1211    runtime: Arc<dyn RuntimeObservability>,
1212    route_id: String,
1213    tls_config: Option<crate::config::ServerTlsConfig>,
1214) -> Result<Arc<ServerHandle>, CamelError> {
1215    let rt = Arc::clone(&runtime);
1216    let rid = route_id.clone();
1217    let (host_owned, port) = key;
1218    let listener = match source {
1219        ListenerSource::Bind => {
1220            let addr = format!("{host_owned}:{port}");
1221            tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1222                CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1223            })?
1224        }
1225        ListenerSource::Staged(listener) => listener,
1226    };
1227    let bound_addr = listener
1228        .local_addr()
1229        .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1230    let server_exited = tokio_util::sync::CancellationToken::new();
1231    let registry = HttpRouteRegistry::new_with_server_exited(server_exited.clone());
1232    let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1233    // Constructed once in the TLS branch so they can be retained
1234    // on ServerHandle for the reload handler (Task 7).
1235    let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1236    let tls_source: Option<ServerTlsSource>;
1237    let server_task = if let Some(ref tls) = tls_config {
1238        let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1239        let source = ServerTlsSource {
1240            cert_path: std::path::PathBuf::from(&tls.cert_path),
1241            key_path: std::path::PathBuf::from(&tls.key_path),
1242            client_ca_path: None,
1243        };
1244        // Build the RustlsConfig once — clone() is cheap (Arc
1245        // internally) and shares the ArcSwap the reload handler
1246        // will mutate via reload_from_config().
1247        let rustls_cfg =
1248            axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1249        tls_rustls_cfg = Some(rustls_cfg.clone());
1250        tls_source = Some(source);
1251        // Convert tokio listener to std for axum-server
1252        let std_listener = listener.into_std().map_err(|e| {
1253            CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1254        })?;
1255        tokio::spawn(run_axum_server_tls(
1256            std_listener,
1257            rustls_cfg,
1258            registry.clone(),
1259            max_request_body,
1260            max_response_body,
1261            Arc::clone(&inflight),
1262            Arc::clone(&rt),
1263            rid.clone(),
1264        ))
1265    } else {
1266        tls_rustls_cfg = None;
1267        tls_source = None;
1268        tokio::spawn(run_axum_server(
1269            listener,
1270            registry.clone(),
1271            max_request_body,
1272            max_response_body,
1273            Arc::clone(&inflight),
1274            Arc::clone(&rt),
1275            rid.clone(),
1276        ))
1277    };
1278    let addr_for_monitor = format!("{host_owned}:{port}");
1279    let server_abort = server_task.abort_handle();
1280    let monitor_task = tokio::spawn(monitor_axum_task(
1281        server_task,
1282        addr_for_monitor,
1283        Arc::clone(&rt),
1284        rid,
1285        server_exited,
1286    ));
1287    let handle = ServerHandle {
1288        registry,
1289        bound_addr,
1290        max_request_body,
1291        max_response_body,
1292        max_inflight_requests,
1293        is_tls: tls_config.is_some(),
1294        tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1295        tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1296        monitor_task,
1297        server_abort,
1298        tls_config: tls_rustls_cfg,
1299        tls_source,
1300    };
1301    // Register reload handler (exactly-once: inside OnceCell init closure).
1302    // Note: HTTP servers are process-lifetime (no release/eviction path),
1303    // so handlers are never unregistered. If eviction is added later,
1304    // add TlsReloadRegistry::global().unregister() there.
1305    if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1306    {
1307        let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1308            tls_cfg.clone(),
1309            source.clone(),
1310            host_owned.clone(),
1311            port,
1312        ));
1313        camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1314    }
1315    Ok(Arc::new(handle))
1316}
1317
1318// ---------------------------------------------------------------------------
1319// Axum server
1320// ---------------------------------------------------------------------------
1321
1322use axum::{
1323    Router,
1324    body::Body as AxumBody,
1325    extract::{Request, State},
1326    http::{Response, StatusCode},
1327    response::IntoResponse,
1328};
1329
1330#[derive(Clone)]
1331pub(crate) struct AppState {
1332    registry: HttpRouteRegistry,
1333    max_request_body: usize,
1334    max_response_body: usize,
1335    inflight: Arc<tokio::sync::Semaphore>,
1336}
1337
1338/// Hard wall-clock limit for one inbound request on the consumer side
1339/// (audit 2026-08-31, F2-1). A slow-drip client otherwise holds an
1340/// `inflight` semaphore permit (and its connection) indefinitely, starving
1341/// the consumer into 503s. 30s matches the documented component default
1342/// timeouts. Applies to the whole dispatch; streaming bodies are additionally
1343/// protected by the byte cap in `dispatch_handler`.
1344const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1345
1346async fn run_axum_server(
1347    listener: tokio::net::TcpListener,
1348    registry: HttpRouteRegistry,
1349    max_request_body: usize,
1350    max_response_body: usize,
1351    inflight: Arc<tokio::sync::Semaphore>,
1352    runtime: Arc<dyn RuntimeObservability>,
1353    route_id: String,
1354) {
1355    let state = AppState {
1356        registry,
1357        max_request_body,
1358        max_response_body,
1359        inflight,
1360    };
1361    let app = Router::new()
1362        .fallback(dispatch_handler)
1363        .with_state(state)
1364        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1365            StatusCode::REQUEST_TIMEOUT,
1366            CONSUMER_REQUEST_TIMEOUT,
1367        ));
1368
1369    axum::serve(listener, app).await.unwrap_or_else(|e| {
1370        runtime
1371            .metrics()
1372            .increment_errors(&route_id, "e:http:accept");
1373        // log-policy: outside-contract
1374        tracing::error!(error = %e, "Axum server error");
1375    });
1376}
1377
1378#[allow(clippy::too_many_arguments)]
1379async fn run_axum_server_tls(
1380    listener: std::net::TcpListener,
1381    tls_cfg: axum_server::tls_rustls::RustlsConfig,
1382    registry: HttpRouteRegistry,
1383    max_request_body: usize,
1384    max_response_body: usize,
1385    inflight: Arc<tokio::sync::Semaphore>,
1386    runtime: Arc<dyn RuntimeObservability>,
1387    route_id: String,
1388) {
1389    let state = AppState {
1390        registry,
1391        max_request_body,
1392        max_response_body,
1393        inflight,
1394    };
1395    let app = Router::new()
1396        .fallback(dispatch_handler)
1397        .with_state(state)
1398        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1399            StatusCode::REQUEST_TIMEOUT,
1400            CONSUMER_REQUEST_TIMEOUT,
1401        ));
1402
1403    // RustlsConfig is now constructed once in get_or_spawn and retained on
1404    // ServerHandle so the reload handler can call reload_from_config() on it.
1405
1406    // axum-server 0.8: from_tcp_rustls is fallible (TLS acceptor setup).
1407    let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1408        Ok(server) => server,
1409        Err(e) => {
1410            runtime
1411                .metrics()
1412                .increment_errors(&route_id, "e:http:accept-tls");
1413            // log-policy: outside-contract
1414            tracing::error!(error = %e, "Axum TLS server setup error");
1415            return;
1416        }
1417    };
1418
1419    server
1420        .serve(app.into_make_service())
1421        .await
1422        .unwrap_or_else(|e| {
1423            runtime
1424                .metrics()
1425                .increment_errors(&route_id, "e:http:accept-tls");
1426            // log-policy: outside-contract
1427            tracing::error!(error = %e, "Axum TLS server error");
1428        });
1429}
1430
1431/// Monitors the shared Axum server task of one (host, port).
1432///
1433/// On unexpected exit (panic or abort) it records the structured error
1434/// event and cancels the server's `server_exited` token. Every
1435/// `HttpConsumer` hosted on that server observes the cancellation in its
1436/// `start()` loop and returns `Err`, which camel-core's consumer watcher
1437/// turns into a per-route `CrashNotification` → `FailRoute` → supervision
1438/// backoff restart (ADR-0007). A clean exit (`Ok(())` — process shutdown)
1439/// cancels nothing: route stops own their termination.
1440async fn monitor_axum_task(
1441    handle: tokio::task::JoinHandle<()>,
1442    addr: String,
1443    runtime: Arc<dyn RuntimeObservability>,
1444    route_id: String,
1445    server_exited: tokio_util::sync::CancellationToken,
1446) {
1447    match handle.await {
1448        Ok(()) => {
1449            // Clean exit (process shutdown or normal stop)
1450        }
1451        Err(join_err) => {
1452            runtime
1453                .metrics()
1454                .increment_errors(&route_id, "e:http:server-task-exited");
1455            // log-policy: outside-contract
1456            tracing::error!(
1457                addr = %addr,
1458                error = %join_err,
1459                "Axum server task exited unexpectedly — all routes on this port are now dead"
1460            );
1461            // Fail every hosted route's consumer: each `start()` returns Err
1462            // and camel-core emits one CrashNotification per route (ADR-0007
1463            // parity with per-route transport death).
1464            server_exited.cancel();
1465        }
1466    }
1467}
1468
1469/// Load a rustls ServerConfig from PEM cert/key files.
1470/// Adapted from camel-ws lib.rs load_tls_config.
1471fn load_tls_config(
1472    cert_path: &str,
1473    key_path: &str,
1474) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1475    use std::fs::File;
1476    use std::io::BufReader;
1477
1478    let cert_file = File::open(cert_path)
1479        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1480    let key_file = File::open(key_path)
1481        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1482
1483    let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1484        .collect::<Result<Vec<_>, _>>()
1485        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1486
1487    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1488        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1489        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1490
1491    tokio_rustls::rustls::ServerConfig::builder()
1492        .with_no_client_auth()
1493        .with_single_cert(certs, key)
1494        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1495}
1496
1497async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1498    let path = req.uri().path().to_owned();
1499    let method = req.method().to_string();
1500
1501    // Dispatch precedence (spec §7.2 / ADR-0009):
1502    //   1. Exact API path match (legacy `http:` routes without httpMethod)
1503    //   2. Templated API path match (REST, method-aware, by specificity)
1504    //   3. Static mount longest-prefix
1505    //   4. SPA fallback
1506    //
1507    // Legacy exact runs first: it is a cheap HashMap get, and the two
1508    // registries are mutually exclusive per route — a legacy route carries
1509    // no `httpMethod` and lives only in `api_routes`, while a REST-lowered
1510    // route carries `httpMethod` and lives only in `rest_endpoints`. So an
1511    // exact hit can never shadow a REST route that should have matched,
1512    // and running exact-first honours the documented precedence (the prior
1513    // REST-first order let a templated `GET /api/{resource}` steal a
1514    // request meant for an exact `GET /api/users`). Intra-REST method
1515    // disambiguation is handled inside `match_endpoint`, not by this
1516    // ordering. Review C2.
1517    let api_sender = {
1518        let inner = state.registry.inner.read().await;
1519        inner.api_routes.get(&path).cloned()
1520    }; // lock released BEFORE any IO
1521
1522    let (rest_sender, path_params) = if api_sender.is_some() {
1523        // Exact legacy match won — skip the templated scan entirely.
1524        (None, Default::default())
1525    } else {
1526        let inner = state.registry.inner.read().await;
1527        match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1528            rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1529            rest_match::MatchOutcome::Ambiguous => {
1530                // Ambiguous registration should have been rejected at
1531                // lowering time (rest.rs). Reaching here means two
1532                // equal-specificity templates matched one request —
1533                // surface a loud error rather than a silent 404. Review C3.
1534                // log-policy: handler-owned
1535                tracing::warn!(
1536                    method = %method,
1537                    path = %path,
1538                    "ambiguous REST template match — returning 500"
1539                );
1540                return Response::builder()
1541                    .status(StatusCode::INTERNAL_SERVER_ERROR)
1542                    .body(AxumBody::from("Internal Server Error"))
1543                    .expect("infallible"); // allow-unwrap
1544            }
1545            rest_match::MatchOutcome::NotFound => (None, Default::default()),
1546        }
1547    }; // lock released BEFORE any IO
1548
1549    let sender = api_sender.or(rest_sender);
1550
1551    if let Some(sender) = sender {
1552        let query = req.uri().query().unwrap_or("").to_string();
1553        let headers = req.headers().clone();
1554
1555        // Check Content-Length against limit BEFORE opening the stream
1556        let content_length: Option<u64> = headers
1557            .get(http::header::CONTENT_LENGTH)
1558            .and_then(|v| v.to_str().ok())
1559            .and_then(|s| s.parse().ok());
1560
1561        if let Some(len) = content_length
1562            && len > state.max_request_body as u64
1563        {
1564            return Response::builder()
1565                .status(StatusCode::PAYLOAD_TOO_LARGE)
1566                .body(AxumBody::from("Request body exceeds configured limit"))
1567                .expect("infallible"); // allow-unwrap
1568        }
1569
1570        let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1571            Ok(permit) => permit,
1572            Err(_) => {
1573                return Response::builder()
1574                    .status(StatusCode::SERVICE_UNAVAILABLE)
1575                    .body(AxumBody::from("Service Unavailable"))
1576                    .expect("infallible"); // allow-unwrap
1577            }
1578        };
1579
1580        // Build StreamBody from Axum body WITHOUT materializing.
1581        // SECURITY (audit 2026-08-31, F2-1): the Content-Length pre-check above
1582        // cannot see chunked/no-length requests. Wrap the stream with a hard
1583        // byte cap so ANY downstream consumption fails closed once
1584        // max_request_body is exceeded — the cap travels with the body.
1585        let content_type = headers
1586            .get(http::header::CONTENT_TYPE)
1587            .and_then(|v| v.to_str().ok())
1588            .map(|s| s.to_string());
1589
1590        let data_stream: BodyDataStream = req.into_body().into_data_stream();
1591        let max_body = state.max_request_body;
1592        let mut seen: u64 = 0;
1593        let capped_stream =
1594            data_stream
1595                .map_err(|e| CamelError::Io(e.to_string()))
1596                .map(move |chunk| match chunk {
1597                    Ok(bytes) => {
1598                        seen = seen.saturating_add(bytes.len() as u64);
1599                        if seen > max_body as u64 {
1600                            Err(CamelError::ProcessorError(format!(
1601                                "Request body exceeds configured limit of {max_body} bytes"
1602                            )))
1603                        } else {
1604                            Ok(bytes)
1605                        }
1606                    }
1607                    Err(e) => Err(e),
1608                });
1609        let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1610
1611        let stream_body = StreamBody {
1612            stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1613            metadata: StreamMetadata {
1614                size_hint: content_length,
1615                content_type,
1616                origin: None,
1617            },
1618        };
1619
1620        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1621        let envelope = RequestEnvelope {
1622            method,
1623            path,
1624            query,
1625            headers,
1626            body: stream_body,
1627            path_params,
1628            reply_tx,
1629        };
1630
1631        if sender.send(envelope).await.is_err() {
1632            return Response::builder()
1633                .status(StatusCode::SERVICE_UNAVAILABLE)
1634                .body(AxumBody::from("Consumer unavailable"))
1635                .expect("infallible"); // allow-unwrap
1636        }
1637
1638        match reply_rx.await {
1639            Ok(reply) => {
1640                let reply = match reply.body {
1641                    HttpReplyBody::Bytes(b)
1642                        if exceeds_max_response_body(b.len(), state.max_response_body) =>
1643                    {
1644                        HttpReply {
1645                            status: 500,
1646                            headers: vec![],
1647                            body: HttpReplyBody::Bytes(bytes::Bytes::from(
1648                                "Response body exceeds configured limit",
1649                            )),
1650                        }
1651                    }
1652                    _ => reply,
1653                };
1654
1655                let status =
1656                    StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1657                let mut builder = Response::builder().status(status);
1658                for (k, v) in &reply.headers {
1659                    builder = builder.header(k.as_str(), v.as_str());
1660                }
1661                match reply.body {
1662                    HttpReplyBody::Bytes(b) => {
1663                        builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1664                            Response::builder()
1665                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1666                                .body(AxumBody::from("Invalid response headers from consumer"))
1667                                .expect("infallible") // allow-unwrap
1668                        })
1669                    }
1670                    HttpReplyBody::Stream(stream) => builder
1671                        .body(AxumBody::from_stream(stream))
1672                        .unwrap_or_else(|_| {
1673                            Response::builder()
1674                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1675                                .body(AxumBody::from("Invalid response headers from consumer"))
1676                                .expect("infallible") // allow-unwrap
1677                        }),
1678                }
1679            }
1680            Err(_) => Response::builder()
1681                .status(StatusCode::INTERNAL_SERVER_ERROR)
1682                .body(AxumBody::from("Pipeline error"))
1683                .expect("infallible"), // allow-unwrap
1684        }
1685    } else {
1686        // No API route matched — try static mounts
1687        static_dispatch::dispatch_static(&state, req, &path).await
1688    }
1689}
1690
1691fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1692    len > max
1693}
1694
1695fn title_case_header(name: &str) -> String {
1696    name.split('-')
1697        .map(|part| {
1698            let mut chars = part.chars();
1699            match chars.next() {
1700                None => String::new(),
1701                Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1702            }
1703        })
1704        .collect::<Vec<_>>()
1705        .join("-")
1706}
1707
1708// ---------------------------------------------------------------------------
1709// HttpConsumer
1710// ---------------------------------------------------------------------------
1711
1712/// Kernel authentication state captured from a route's [`SecurityContext`]
1713/// (`unify-transport-auth`, Task 2.9).
1714///
1715/// Same construction-order lifecycle as gRPC's `GrpcKernelAuth` (Task 2.1):
1716/// the compiled plan and the provider registry arrive via
1717/// `Consumer::set_security_context` before `start()` accepts requests. A
1718/// context lacking either piece keeps `kernel = None` — a plan without
1719/// providers can never mint a principal (fail-closed, never a silently
1720/// unauthenticated route: the controller's strict-mode dispatch check then
1721/// denies carrier-less Exchanges on non-Public plans).
1722pub(crate) struct HttpKernelAuth {
1723    pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1724    pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1725}
1726
1727impl HttpKernelAuth {
1728    /// Capture the kernel state from a route's security context.
1729    ///
1730    /// `None` unless both the compiled plan and the provider registry are
1731    /// present.
1732    pub(crate) fn from_security_context(
1733        ctx: &camel_component_api::SecurityContext,
1734    ) -> Option<Self> {
1735        Some(Self {
1736            plan: ctx.plan.clone()?,
1737            providers: ctx.providers.clone()?,
1738        })
1739    }
1740}
1741
1742/// Capacity for the per-route RequestEnvelope channel.
1743///
1744/// Each in-flight request holds exactly one `maxInflightRequests` semaphore
1745/// permit from before `send()` until its reply, so at most N envelopes can be
1746/// outstanding at any time. A buffer of N therefore can never fill before the
1747/// semaphore exhausts: dispatcher `send()` calls never park on a full buffer
1748/// and the semaphore stays the single, URI-configurable backpressure point.
1749/// A hardcoded smaller buffer would act as a second, hidden inflight cap
1750/// (rc-3y6j: 64 vs default 1024 permits).
1751///
1752/// `max(1)`: `maxInflightRequests=0` is a representable "reject everything"
1753/// configuration, but `tokio::sync::mpsc::channel(0)` panics — keep consumer
1754/// start panic-free (the empty semaphore still 503s every request).
1755fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1756    max_inflight_requests.max(1)
1757}
1758
1759pub struct HttpConsumer {
1760    config: HttpServerConfig,
1761    /// Runtime observability handle for ADR-0012 metrics and health calls.
1762    runtime: Arc<dyn RuntimeObservability>,
1763    /// Kernel authentication state (plan + providers), set via
1764    /// `set_security_context` before `start()` (Task 2.9). `None` for routes
1765    /// without route-level security (Public under the per-bind gate).
1766    kernel: Option<Arc<HttpKernelAuth>>,
1767}
1768
1769impl HttpConsumer {
1770    pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1771        Self {
1772            config,
1773            runtime,
1774            kernel: None,
1775        }
1776    }
1777}
1778
1779#[async_trait::async_trait]
1780impl Consumer for HttpConsumer {
1781    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1782        use camel_component_api::{Body, Exchange, Message};
1783
1784        let registry = ServerRegistry::global()
1785            .get_or_spawn(
1786                &self.config.host,
1787                self.config.port,
1788                self.config.max_request_body,
1789                self.config.max_response_body,
1790                self.config.max_inflight_requests,
1791                self.runtime.clone(),
1792                ctx.route_id().to_string(),
1793                self.config.tls_config.clone(),
1794            )
1795            .await?;
1796
1797        // Create channel for this path and register it. Capacity matches the
1798        // dispatcher's inflight semaphore (see envelope_channel_capacity) so
1799        // the channel can never become a second backpressure point.
1800        let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1801            envelope_channel_capacity(self.config.max_inflight_requests),
1802        );
1803        // When the from-URI carries `httpMethod=...` (REST-lowered
1804        // route), register the consumer as a method-aware REST endpoint
1805        // so the dispatcher can route by (method, path template).
1806        // Otherwise fall back to the legacy path-only api_routes
1807        // registry. The two registries never overlap for the same
1808        // route: each consumer registers in exactly one of them.
1809        if let Some(method) = self.config.method.clone() {
1810            let segments = rest_match::parse_path_template(&self.config.path);
1811            registry
1812                .register_rest_endpoint(method, segments, env_tx)
1813                .await;
1814        } else {
1815            registry
1816                .register_api_route(self.config.path.clone(), env_tx)
1817                .await;
1818        }
1819
1820        // rc-w1u9: Signal readiness AFTER (1) TcpListener::bind succeeded
1821        // (inside get_or_spawn above), (2) the axum server task was spawned,
1822        // and (3) this route's path/REST endpoint was registered. At this
1823        // point the listener is genuinely accepting connections and any
1824        // request to this route will be dispatched (not 404'd). The runtime
1825        // uses this signal to publish RouteStarted and to release
1826        // ctx.start() so external benchmarks can emit a reliable
1827        // listener-bound marker.
1828        ctx.mark_ready();
1829
1830        let path = self.config.path.clone();
1831        let registry_for_cleanup = registry.clone();
1832        let server_exited = registry.server_exited.clone();
1833        let cancel_token = ctx.cancel_token();
1834        let kernel = self.kernel.clone();
1835        // Set when the loop exits because the shared server died. The
1836        // post-loop cleanup still runs, then `start()` returns Err so
1837        // camel-core's consumer watcher emits a CrashNotification for THIS
1838        // route and supervision backoff engages (ADR-0007).
1839        let mut server_died = false;
1840        loop {
1841            tokio::select! {
1842                _ = ctx.cancelled() => {
1843                    break;
1844                }
1845                _ = server_exited.cancelled() => {
1846                    // Shared transport death: this route's consumer cannot
1847                    // continue. Fail (do NOT hang in Running) — parity with
1848                    // per-route transport death, which also surfaces as a
1849                    // consumer-task error.
1850                    server_died = true;
1851                    break;
1852                }
1853                envelope = env_rx.recv() => {
1854                    let Some(envelope) = envelope else { break; };
1855
1856                    // Build Exchange from HTTP request
1857                    let mut msg = Message::default();
1858
1859                    // Set standard Camel HTTP headers
1860                    msg.set_header("CamelHttpMethod",
1861                        serde_json::Value::String(envelope.method.clone()));
1862                    msg.set_header("CamelHttpPath",
1863                        serde_json::Value::String(envelope.path.clone()));
1864                    msg.set_header("CamelHttpQuery",
1865                        serde_json::Value::String(envelope.query.clone()));
1866
1867                    // Set path-parameter headers from REST template
1868                    // match. Expert guidance E2: the consumer is
1869                    // responsible for translating the dispatcher's
1870                    // matched params into `CamelHttpPath_<param>`
1871                    // headers on the Exchange, matching the convention
1872                    // used by Camel HTTP for templated routes.
1873                    for (param_name, param_value) in &envelope.path_params {
1874                        msg.set_header(
1875                            format!("CamelHttpPath_{param_name}"),
1876                            serde_json::Value::String(param_value.clone()),
1877                        );
1878                    }
1879
1880                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
1881                    for (k, v) in &envelope.headers {
1882                        if let Ok(val_str) = v.to_str() {
1883                            msg.set_header(
1884                                title_case_header(k.as_str()),
1885                                serde_json::Value::String(val_str.to_string()),
1886                            );
1887                        }
1888                    }
1889
1890                    // Body: always arrives as Body::Stream (native streaming)
1891                    // Routes can call into_bytes() if they need to materialize
1892                    msg.body = Body::Stream(envelope.body);
1893
1894                    #[allow(unused_mut)]
1895                    let mut exchange = Exchange::new(msg);
1896
1897                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1898                    #[cfg(feature = "otel")]
1899                    {
1900                        let headers: HashMap<String, String> = envelope
1901                            .headers
1902                            .iter()
1903                            .filter_map(|(k, v)| {
1904                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1905                            })
1906                            .collect();
1907                        camel_otel::extract_into_exchange(&mut exchange, &headers);
1908                    }
1909
1910                    let reply_tx = envelope.reply_tx;
1911                    let sender = ctx.sender().clone();
1912                    let path_clone = path.clone();
1913                    let cancel = cancel_token.clone();
1914                    // Task 2.9 boundary-auth inputs: the raw header map and
1915                    // the request URI (path + query) feed kernel credential
1916                    // extraction inside the per-request task.
1917                    let auth_headers = envelope.headers.clone();
1918                    let auth_uri: http::Uri = {
1919                        let full = if envelope.query.is_empty() {
1920                            envelope.path.clone()
1921                        } else {
1922                            format!("{}?{}", envelope.path, envelope.query)
1923                        };
1924                        // A malformed path cannot become a valid `Uri`; the
1925                        // empty default then carries no credentials, so
1926                        // extraction finds nothing and authn fails closed.
1927                        full.parse().unwrap_or_default()
1928                    };
1929                    let kernel = kernel.clone();
1930
1931                    // Spawn a task to handle this request concurrently
1932                    //
1933                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
1934                    // true concurrent request processing. This change was introduced as part of the
1935                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
1936                    //
1937                    // Rationale:
1938                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
1939                    //    the consumer's main loop until the pipeline processing completes
1940                    // 2. This blocking would prevent multiple HTTP requests from being processed
1941                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
1942                    // 3. The channel would never have multiple exchanges buffered simultaneously,
1943                    //    defeating the purpose of pipeline-side concurrency
1944                    // 4. By spawning a task per request, we allow the consumer loop to continue
1945                    //    accepting new requests while existing ones are processed in the pipeline
1946                    //
1947                    // This approach effectively decouples request acceptance from pipeline processing,
1948                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
1949                    // by the pipeline when ConcurrencyModel::Concurrent is active.
1950                    tokio::spawn(async move {
1951                        // Check for cancellation before sending to pipeline.
1952                        // Returns 503 (Service Unavailable) instead of letting the request
1953                        // enter a shutting-down pipeline. This is a behavioral change from
1954                        // the pre-concurrency implementation where cancellation during
1955                        // processing would result in a 500 (Internal Server Error).
1956                        // 503 is more semantically correct: the server is temporarily
1957                        // unable to handle the request due to shutdown.
1958                        if cancel.is_cancelled() {
1959                            let _ = reply_tx.send(HttpReply {
1960                                status: 503,
1961                                headers: vec![],
1962                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1963                            });
1964                            return;
1965                        }
1966
1967                        // ADR-0061 Task 2.9: kernel authentication at the
1968                        // request boundary. A `Public` plan passes through
1969                        // with no extraction; any other mode extracts per
1970                        // the plan's sources, authenticates through the
1971                        // kernel, and installs the typed carrier BEFORE the
1972                        // pipeline runs. A denial renders in the HTTP idiom
1973                        // (401 via `pipeline_error_to_reply`) and the route
1974                        // body never sees the request.
1975                        if let Some(kernel) = kernel.as_ref()
1976                            && !matches!(
1977                                kernel.plan.access_mode,
1978                                camel_api::security_policy::AccessMode::Public
1979                            )
1980                        {
1981                            let principal = match camel_auth::extract_token_multi(
1982                                &auth_headers,
1983                                &auth_uri,
1984                                &kernel.plan.credential_sources,
1985                            ) {
1986                                Some(extracted) => {
1987                                    match camel_auth::kernel_authenticate(
1988                                        &kernel.plan,
1989                                        &kernel.providers,
1990                                        &extracted,
1991                                    )
1992                                    .await
1993                                    {
1994                                        Ok(principal) => principal,
1995                                        Err(e) => {
1996                                            // log-policy: handler-owned
1997                                            tracing::warn!(
1998                                                path = %path_clone,
1999                                                error = %e,
2000                                                "HTTP request authentication failed"
2001                                            );
2002                                            let _ = reply_tx.send(pipeline_error_to_reply(
2003                                                e,
2004                                                &path_clone,
2005                                            ));
2006                                            return;
2007                                        }
2008                                    }
2009                                }
2010                                None => {
2011                                    // log-policy: handler-owned
2012                                    tracing::warn!(
2013                                        path = %path_clone,
2014                                        "HTTP request rejected: no credential found in any source"
2015                                    );
2016                                    let _ = reply_tx.send(pipeline_error_to_reply(
2017                                        CamelError::Unauthenticated(
2018                                            "no credential found in any source".to_string(),
2019                                        ),
2020                                        &path_clone,
2021                                    ));
2022                                    return;
2023                                }
2024                            };
2025                            camel_auth::install_carrier(&mut exchange, &principal);
2026                        }
2027
2028                        // Send through pipeline and await result
2029                        let (tx, rx) = tokio::sync::oneshot::channel();
2030                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
2031                            exchange,
2032                            reply_tx: Some(tx),
2033                        };
2034
2035                        let result = match sender.send(envelope).await {
2036                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
2037                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
2038                        }
2039                        .and_then(|r| r);
2040
2041                        let reply = match result {
2042                            Ok(out) => {
2043                                let status = out
2044                                    .input
2045                                    .header("CamelHttpResponseCode")
2046                                    .and_then(|v| {
2047                                        let raw = v.as_u64()
2048                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2049                                        let code = raw as u16;
2050                                        (100..1000).contains(&code).then_some(code)
2051                                    })
2052                                    .unwrap_or(200);
2053
2054                                let user_content_type = out
2055                                    .input
2056                                    .header("Content-Type")
2057                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
2058
2059                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2060                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2061                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2062                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2063                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2064                                        v.to_string().into_bytes(),
2065                                    )), Some("application/json".to_string())),
2066                                    Body::Stream(s) => {
2067                                        let ct = s.metadata.content_type.clone();
2068                                        match s.stream.lock().await.take() {
2069                                            Some(stream) => (
2070                                                HttpReplyBody::Stream(stream),
2071                                                ct,
2072                                            ),
2073                                            None => {
2074                                                // log-policy: system-broken
2075                                                tracing::error!(
2076                                                    "Body::Stream already consumed before HTTP reply — returning 500"
2077                                                );
2078                                                let error_reply = HttpReply {
2079                                                    status: 500,
2080                                                    headers: vec![],
2081                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2082                                                };
2083                                                if reply_tx.send(error_reply).is_err() {
2084                                                    debug!("reply_tx dropped before error reply could be sent");
2085                                                }
2086                                                return;
2087                                            }
2088                                        }
2089                                    }
2090                                    // Empty and future variants produce an empty reply body.
2091                                    _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2092                                };
2093
2094                                let resp_headers = select_response_headers(
2095                                    &out.input.headers,
2096                                    user_content_type,
2097                                    inferred_content_type,
2098                                );
2099
2100                                HttpReply {
2101                                    status,
2102                                    headers: resp_headers,
2103                                    body: reply_body,
2104                                }
2105                            }
2106                            Err(e) => {
2107                                pipeline_error_to_reply(e, &path_clone)
2108                            }
2109                        };
2110
2111                        // Reply to Axum handler (ignore error if client disconnected)
2112                        let _ = reply_tx.send(reply);
2113                    });
2114                }
2115            }
2116        }
2117
2118        // Deregister this consumer. Mirror the registration choice:
2119        // REST-registered consumers remove their (method, path) endpoint
2120        // WITHOUT touching sibling verbs on the same template (review C1);
2121        // legacy consumers clean up api_routes.
2122        if let Some(method) = &self.config.method {
2123            registry_for_cleanup
2124                .unregister_rest_endpoint(method, &path)
2125                .await;
2126        } else {
2127            registry_for_cleanup.unregister_api_route(&path).await;
2128        }
2129
2130        // Leave the shared-server entry: `unregister` is a no-op today (no
2131        // refcount exists — stale D-L10 wording removed, rc-szmob review).
2132        // Dead servers are evicted lazily by `get_or_spawn_internal`, which
2133        // checks `monitor_task.is_finished()` and rebinds on the next spawn
2134        // (e.g. a supervision restart after this consumer's Err).
2135        ServerRegistry::global()
2136            .unregister(&self.config.host, self.config.port)
2137            .await;
2138
2139        if server_died {
2140            // log-policy: system-broken
2141            tracing::error!(
2142                host = %self.config.host,
2143                port = self.config.port,
2144                path = %path,
2145                "Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
2146            );
2147            return Err(CamelError::RouteError(format!(
2148                "shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
2149                self.config.host, self.config.port
2150            )));
2151        }
2152
2153        Ok(())
2154    }
2155
2156    async fn stop(&mut self) -> Result<(), CamelError> {
2157        Ok(())
2158    }
2159
2160    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2161        camel_component_api::ConcurrencyModel::Concurrent { max: None }
2162    }
2163
2164    // rc-w1u9: HTTP consumer binds a TcpListener inside start() (via
2165    // ServerRegistry::get_or_spawn) and only THEN can it accept connections.
2166    // Opting into Explicit startup makes ctx.start() await the bind+register
2167    // completion so listeners fail fast on bind errors (previously a silent
2168    // background log) and external markers can reliably detect listener-bound
2169    // state.
2170    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2171        camel_component_api::ConsumerStartupMode::Explicit
2172    }
2173
2174    // Task 2.9: capture the kernel state (compiled plan + provider registry)
2175    // wired by the route controller before start(). See `HttpKernelAuth`.
2176    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2177        self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2178    }
2179}
2180
2181// ---------------------------------------------------------------------------
2182// HttpComponent / HttpsComponent
2183// ---------------------------------------------------------------------------
2184
2185pub struct HttpComponent {
2186    config: HttpConfig,
2187    pinned_cache: std::sync::Arc<PinnedClientCache>,
2188    client: reqwest::Client,
2189    /// Set at construction when `tls.strict` is on and the configured
2190    /// material fails to load; surfaced as an endpoint-creation failure
2191    /// (rc-ayrwk).
2192    strict_tls_error: Option<CamelError>,
2193}
2194
2195#[cfg(test)]
2196thread_local! {
2197    static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2198}
2199
2200pub(crate) fn build_client(
2201    config: &HttpConfig,
2202    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2203) -> reqwest::Client {
2204    #[cfg(test)]
2205    BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2206
2207    let mut builder = reqwest::Client::builder()
2208        .no_proxy() // CRITICAL: env proxies bypass resolve_to_addrs
2209        .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2210        .pool_max_idle_per_host(config.pool_max_idle_per_host)
2211        .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2212
2213    // Redirects are always handled manually in the producer's send path
2214    // so that each hop can be SSRF-validated. reqwest's built-in redirect
2215    // policy is sync and cannot perform async DNS resolution or SSRF checks.
2216    builder = builder.redirect(reqwest::redirect::Policy::none());
2217
2218    if let Some((host, addrs)) = resolve_override {
2219        builder = builder.resolve_to_addrs(host, addrs);
2220    }
2221
2222    if let Some(tls) = &config.tls
2223        && tls.enabled
2224    {
2225        if tls.insecure || !tls.verify_peer {
2226            // log-policy: handler-owned
2227            tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2228            builder = builder.danger_accept_invalid_certs(true);
2229        }
2230
2231        if let Some(ca_path) = &tls.ca_cert_path {
2232            // Audit 2026-08-31, F2-7: a configured CA that fails to load must
2233            // never degrade silently to system roots. Loud warn (config error
2234            // class: fail-fast would break existing deployments relying on the
2235            // fallback; the warning is the operator signal).
2236            match std::fs::read(ca_path) {
2237                Ok(ca_bytes) => {
2238                    // Under the rustls backend `Certificate::from_pem`
2239                    // never fails (it defers parsing), so the parse-error
2240                    // warn below is effectively dead and a file with zero
2241                    // parseable PEM CERTIFICATE sections would silently
2242                    // contribute no roots. Warn on that case explicitly
2243                    // (e_glm stage-4 finding 1).
2244                    let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2245                        .filter(|r| r.is_ok())
2246                        .count();
2247                    if pem_sections == 0 {
2248                        // log-policy: handler-owned
2249                        tracing::warn!(
2250                            "configured CA certificate contains no parseable PEM CERTIFICATE section — falling back to system roots"
2251                        );
2252                    }
2253                    match reqwest::Certificate::from_pem(&ca_bytes)
2254                        .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2255                    {
2256                        Ok(ca_cert) => {
2257                            builder = builder.add_root_certificate(ca_cert);
2258                        }
2259                        Err(e) => {
2260                            // log-policy: handler-owned
2261                            tracing::warn!(
2262                                error = %e,
2263                                "configured CA certificate failed to parse — falling back to system roots"
2264                            );
2265                        }
2266                    }
2267                }
2268                Err(e) => {
2269                    // log-policy: handler-owned
2270                    tracing::warn!(
2271                        error = %e,
2272                        "configured CA certificate file unreadable — falling back to system roots"
2273                    );
2274                }
2275            }
2276        }
2277
2278        // mTLS identity: BOTH files must load and parse, or the identity is
2279        // absent. A partial failure previously meant silently downgrading to
2280        // non-mTLS — now loud.
2281        if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2282            match (std::fs::read(cert_path), std::fs::read(key_path)) {
2283                (Ok(cert_bytes), Ok(key_bytes)) => {
2284                    let mut identity_pem = cert_bytes;
2285                    identity_pem.extend_from_slice(&key_bytes);
2286                    match reqwest::Identity::from_pem(&identity_pem) {
2287                        Ok(identity) => {
2288                            builder = builder.identity(identity);
2289                        }
2290                        Err(e) => {
2291                            // log-policy: handler-owned
2292                            tracing::warn!(
2293                                error = %e,
2294                                "configured mTLS identity failed to parse — client certificate NOT used"
2295                            );
2296                        }
2297                    }
2298                }
2299                (cert_r, key_r) => {
2300                    // log-policy: handler-owned
2301                    tracing::warn!(
2302                        cert_ok = cert_r.is_ok(),
2303                        key_ok = key_r.is_ok(),
2304                        "configured mTLS cert/key file unreadable — client certificate NOT used"
2305                    );
2306                }
2307            }
2308        }
2309    }
2310
2311    builder
2312        .build()
2313        .expect("reqwest::Client::build() with valid config should not fail") // allow-unwrap
2314}
2315
2316/// Eagerly load and parse the configured TLS material when strict mode is
2317/// on (audit 2026-08-31 R3 / rc-ayrwk). Returns the first failure as an
2318/// `EndpointCreationFailed` error; `None` when the material loads, or when
2319/// strict mode is off (the permissive F2-7 fallback with its loud warns
2320/// stays the default for back-compat).
2321///
2322/// Mirrors the four load sites in [`build_client`]: CA unreadable, CA
2323/// unparseable, mTLS cert/key unreadable, mTLS identity unparseable.
2324fn strict_tls_error(config: &HttpConfig) -> Option<CamelError> {
2325    let tls = config.tls.as_ref()?;
2326    if !tls.enabled || !tls.strict {
2327        return None;
2328    }
2329    if let Some(ca_path) = &tls.ca_cert_path {
2330        match std::fs::read(ca_path) {
2331            Ok(ca_bytes) => {
2332                // `reqwest::Certificate::{from_pem,from_der}` defer parsing
2333                // under rustls, and unparseable entries are silently
2334                // skipped at client build — so strict validation must be
2335                // eager AND match what the backend actually enforces:
2336                // a PEM bundle with at least one parseable CERTIFICATE
2337                // section (rustls-pemfile). A raw-DER file is rejected
2338                // outright: the rustls backend never honors lone-DER
2339                // bytes here (they wrap unvalidated and are dropped at
2340                // root-store insertion), so certifying one under strict
2341                // would certify an unenforced config (e_glm stage-4
2342                // finding 1). Operators convert DER bundles to PEM.
2343                let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2344                    .filter(|r| r.is_ok())
2345                    .count();
2346                if pem_sections == 0 {
2347                    return Some(CamelError::EndpointCreationFailed(format!(
2348                        "tls.strict: configured CA certificate '{ca_path}' has no \
2349                         parseable PEM CERTIFICATE section (DER bundles are not \
2350                         enforced by the TLS backend — convert to PEM)"
2351                    )));
2352                }
2353            }
2354            Err(e) => {
2355                return Some(CamelError::EndpointCreationFailed(format!(
2356                    "tls.strict: configured CA certificate '{ca_path}' is unreadable: {e}"
2357                )));
2358            }
2359        }
2360    }
2361    // A half-configured mTLS pair (cert XOR key) previously degraded
2362    // silently to non-mTLS even under strict — reject it (e_glm stage-4
2363    // finding 2).
2364    if tls.client_cert_path.is_some() != tls.client_key_path.is_some() {
2365        return Some(CamelError::EndpointCreationFailed(
2366            "tls.strict: mTLS requires BOTH client_cert_path and client_key_path".to_string(),
2367        ));
2368    }
2369    if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2370        match (std::fs::read(cert_path), std::fs::read(key_path)) {
2371            (Ok(mut cert_bytes), Ok(key_bytes)) => {
2372                cert_bytes.extend_from_slice(&key_bytes);
2373                if reqwest::Identity::from_pem(&cert_bytes).is_err() {
2374                    return Some(CamelError::EndpointCreationFailed(
2375                        "tls.strict: configured mTLS identity failed to parse".to_string(),
2376                    ));
2377                }
2378            }
2379            _ => {
2380                return Some(CamelError::EndpointCreationFailed(
2381                    "tls.strict: configured mTLS cert/key files are unreadable".to_string(),
2382                ));
2383            }
2384        }
2385    }
2386    None
2387}
2388
2389#[cfg(test)]
2390pub(crate) fn build_client_call_count() -> u64 {
2391    BUILD_CLIENT_CALLS.with(|c| c.get())
2392}
2393
2394impl HttpComponent {
2395    pub fn new() -> Self {
2396        let config = HttpConfig::default();
2397        let strict_err = strict_tls_error(&config);
2398        Self {
2399            client: build_client(&config, None),
2400            config,
2401            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2402                PINNED_CLIENT_TTL,
2403                PINNED_CLIENT_MAX_ENTRIES,
2404            )),
2405            strict_tls_error: strict_err,
2406        }
2407    }
2408
2409    pub fn with_config(config: HttpConfig) -> Self {
2410        let strict_err = strict_tls_error(&config);
2411        Self {
2412            client: build_client(&config, None),
2413            config,
2414            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2415                PINNED_CLIENT_TTL,
2416                PINNED_CLIENT_MAX_ENTRIES,
2417            )),
2418            strict_tls_error: strict_err,
2419        }
2420    }
2421
2422    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2423        match config {
2424            Some(cfg) => Self::with_config(cfg),
2425            None => Self::new(),
2426        }
2427    }
2428}
2429
2430impl Default for HttpComponent {
2431    fn default() -> Self {
2432        Self::new()
2433    }
2434}
2435
2436impl Component for HttpComponent {
2437    fn scheme(&self) -> &str {
2438        "http"
2439    }
2440
2441    fn metadata(&self) -> ComponentMetadata {
2442        HttpEndpointConfig::metadata()
2443    }
2444
2445    fn create_endpoint(
2446        &self,
2447        uri: &str,
2448        ctx: &dyn camel_component_api::ComponentContext,
2449    ) -> Result<Box<dyn Endpoint>, CamelError> {
2450        if let Some(err) = &self.strict_tls_error {
2451            return Err(err.clone());
2452        }
2453        self.config.validate()?;
2454        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2455        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2456        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2457            server_config.host.clone(),
2458            server_config.port,
2459        )));
2460        self.pinned_cache
2461            .wire(HttpComponentKind::Http, ctx.metrics());
2462        Ok(Box::new(HttpEndpoint {
2463            uri: uri.to_string(),
2464            config,
2465            server_config,
2466            client: self.client.clone(),
2467            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2468            http_config: self.config.clone(),
2469        }))
2470    }
2471}
2472
2473pub struct HttpsComponent {
2474    config: HttpConfig,
2475    pinned_cache: std::sync::Arc<PinnedClientCache>,
2476    client: reqwest::Client,
2477    /// Set at construction when `tls.strict` is on and the configured
2478    /// material fails to load; surfaced as an endpoint-creation failure
2479    /// (rc-ayrwk).
2480    strict_tls_error: Option<CamelError>,
2481}
2482
2483impl HttpsComponent {
2484    pub fn new() -> Self {
2485        let config = HttpConfig::default();
2486        let strict_err = strict_tls_error(&config);
2487        Self {
2488            client: build_client(&config, None),
2489            config,
2490            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2491                PINNED_CLIENT_TTL,
2492                PINNED_CLIENT_MAX_ENTRIES,
2493            )),
2494            strict_tls_error: strict_err,
2495        }
2496    }
2497
2498    pub fn with_config(config: HttpConfig) -> Self {
2499        let strict_err = strict_tls_error(&config);
2500        Self {
2501            client: build_client(&config, None),
2502            config,
2503            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2504                PINNED_CLIENT_TTL,
2505                PINNED_CLIENT_MAX_ENTRIES,
2506            )),
2507            strict_tls_error: strict_err,
2508        }
2509    }
2510
2511    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2512        match config {
2513            Some(cfg) => Self::with_config(cfg),
2514            None => Self::new(),
2515        }
2516    }
2517}
2518
2519impl Default for HttpsComponent {
2520    fn default() -> Self {
2521        Self::new()
2522    }
2523}
2524
2525impl Component for HttpsComponent {
2526    fn scheme(&self) -> &str {
2527        "https"
2528    }
2529
2530    fn metadata(&self) -> ComponentMetadata {
2531        // HTTPS shares the same URI option surface and capabilities as HTTP.
2532        // Only the scheme and description differ.
2533        let mut meta = HttpEndpointConfig::metadata();
2534        meta.scheme = "https".to_string();
2535        meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2536        meta
2537    }
2538
2539    fn create_endpoint(
2540        &self,
2541        uri: &str,
2542        ctx: &dyn camel_component_api::ComponentContext,
2543    ) -> Result<Box<dyn Endpoint>, CamelError> {
2544        if let Some(err) = &self.strict_tls_error {
2545            return Err(err.clone());
2546        }
2547        self.config.validate()?;
2548        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2549        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2550        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2551            server_config.host.clone(),
2552            server_config.port,
2553        )));
2554        self.pinned_cache
2555            .wire(HttpComponentKind::Https, ctx.metrics());
2556        Ok(Box::new(HttpEndpoint {
2557            uri: uri.to_string(),
2558            config,
2559            server_config,
2560            client: self.client.clone(),
2561            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2562            http_config: self.config.clone(),
2563        }))
2564    }
2565}
2566
2567// ---------------------------------------------------------------------------
2568// HttpEndpoint
2569// ---------------------------------------------------------------------------
2570
2571struct HttpEndpoint {
2572    uri: String,
2573    config: HttpEndpointConfig,
2574    server_config: HttpServerConfig,
2575    client: reqwest::Client,
2576    pinned_cache: std::sync::Arc<PinnedClientCache>,
2577    http_config: HttpConfig,
2578}
2579
2580impl Endpoint for HttpEndpoint {
2581    fn uri(&self) -> &str {
2582        &self.uri
2583    }
2584
2585    fn create_consumer(
2586        &self,
2587        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2588    ) -> Result<Box<dyn Consumer>, CamelError> {
2589        // Scheme/config consistency check (spec §5) — uses parsed scheme
2590        // from HttpServerConfig, not a fragile port-443 heuristic.
2591        let scheme_is_https = self.server_config.scheme == "https";
2592        let has_tls = self.server_config.tls_config.is_some();
2593
2594        if scheme_is_https && !has_tls {
2595            return Err(CamelError::EndpointCreationFailed(
2596                "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2597            ));
2598        }
2599        if !scheme_is_https && has_tls {
2600            return Err(CamelError::EndpointCreationFailed(
2601                "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2602            ));
2603        }
2604        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2605    }
2606
2607    fn create_producer(
2608        &self,
2609        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2610        _ctx: &ProducerContext,
2611    ) -> Result<BoxProcessor, CamelError> {
2612        let producer = HttpProducer {
2613            config: Arc::new(self.config.clone()),
2614            client: self.client.clone(),
2615            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2616            http_config: Arc::new(self.http_config.clone()),
2617            runtime: rt,
2618        };
2619        if let Some(ref provider) = self.config.token_provider {
2620            let layer = BearerTokenLayer::new(Arc::clone(provider));
2621            Ok(BoxProcessor::new(layer.layer(producer)))
2622        } else {
2623            Ok(BoxProcessor::new(producer))
2624        }
2625    }
2626}
2627
2628// ---------------------------------------------------------------------------
2629// HttpProducer
2630// ---------------------------------------------------------------------------
2631
2632#[derive(Clone)]
2633struct HttpProducer {
2634    config: Arc<HttpEndpointConfig>,
2635    client: reqwest::Client,
2636    pinned_cache: std::sync::Arc<PinnedClientCache>,
2637    http_config: Arc<HttpConfig>,
2638    /// Runtime observability handle powering the component-ops facade at
2639    /// the request boundary (`("http","request")`, dashboard-observability
2640    /// Task 4.3). The retained `e:http:accept*` labels are consumer-side
2641    /// (server accept loop) — different boundary, no collision with
2642    /// `e:http:request`.
2643    runtime: Arc<dyn RuntimeObservability>,
2644}
2645
2646impl HttpProducer {
2647    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2648        if let Some(ref method) = config.http_method {
2649            return method.to_uppercase();
2650        }
2651        if let Some(method) = exchange
2652            .input
2653            .header("CamelHttpMethod")
2654            .and_then(|v| v.as_str())
2655        {
2656            return method.to_uppercase();
2657        }
2658        if !exchange.input.body.is_empty() {
2659            return "POST".to_string();
2660        }
2661        "GET".to_string()
2662    }
2663
2664    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
2665        // bridgeEndpoint=true: exchange URL headers (CamelHttpUri,
2666        // CamelHttpPath, CamelHttpQuery) are ignored per Apache Camel
2667        // bridging semantics. The endpoint's own query still rides: the
2668        // same raw-preserving, consumed-option-filtered query as the
2669        // non-bridge path (bridgeEndpoint itself is a consumed option),
2670        // with programmatic query_params appending absent keys after the
2671        // raw base. This check MUST come before the CamelHttpUri override
2672        // so bridging wins over that header.
2673        if config.bridge_endpoint {
2674            let Some(query) = resolve_endpoint_query(config)? else {
2675                return Ok(config.base_url.clone());
2676            };
2677            // Validation only (rc-ph7z2): a malformed base still errors
2678            // through the redacted-diagnostic path below. The parsed value
2679            // is NEVER re-emitted — assembly is verbatim string
2680            // composition, authored bytes end-to-end: no WHATWG
2681            // normalization (dot-segment collapse, default-port strip,
2682            // scheme/host lowercasing), matching every other arm (Papal
2683            // Direction A).
2684            let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
2685                CamelError::ProcessorError(format!(
2686                    "invalid base URL '{}': {e}",
2687                    redact_url_for_diagnostics(&config.base_url)
2688                ))
2689            })?;
2690            let mut url = config.base_url.clone();
2691            url.push('?');
2692            url.push_str(&query);
2693            return Ok(url);
2694        }
2695
2696        if let Some(uri) = exchange
2697            .input
2698            .header("CamelHttpUri")
2699            .and_then(|v| v.as_str())
2700        {
2701            // Host fence (allowedUriHosts): opt-in, fail-closed. Evaluated
2702            // on the raw override before any path/query assembly; a
2703            // rejection renders the URL only through the diagnostics
2704            // redaction path (ADR-0051).
2705            if let Some(fence) = &config.allowed_uri_hosts
2706                && !uri_host_allowed(uri, fence)?
2707            {
2708                return Err(CamelError::ProcessorError(format!(
2709                    "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
2710                    redact_url_for_diagnostics(uri)
2711                )));
2712            }
2713            // The override replaces the base URL; its own query is the
2714            // higher-precedence source for composition (ADR-0071) — the
2715            // endpoint base query does not ride an override. Split at the
2716            // first `?` so CamelHttpPath applies to the path component
2717            // and the queries merge at pair level, never a second `?`
2718            // marker.
2719            let (base, override_query) = match uri.split_once('?') {
2720                Some((base, query)) => (base, Some(query)),
2721                None => (uri, None),
2722            };
2723            // Resolve-time span validation for the override URI's own query
2724            // (rc-m4xk1): a forbidden byte is a resolve error naming the
2725            // byte, never a verbatim ride that later surfaces as a reqwest
2726            // send error. Covers both downstream arms — the verbatim push
2727            // and merge_header_query, which validates only the header side.
2728            if let Some(query) = override_query {
2729                for (_key, span) in raw_query_pairs(query)? {
2730                    validate_raw_query_span(span)?;
2731                }
2732            }
2733            let mut url = base.to_string();
2734            if let Some(path) = exchange
2735                .input
2736                .header("CamelHttpPath")
2737                .and_then(|v| v.as_str())
2738            {
2739                if !url.ends_with('/') && !path.starts_with('/') {
2740                    url.push('/');
2741                }
2742                url.push_str(path);
2743            }
2744            if let Some(query) = exchange
2745                .input
2746                .header("CamelHttpQuery")
2747                .and_then(|v| v.as_str())
2748            {
2749                if let Some(merged) = merge_header_query(override_query, query)? {
2750                    url.push('?');
2751                    url.push_str(&merged);
2752                }
2753                return Ok(url);
2754            }
2755            if let Some(query) = override_query {
2756                url.push('?');
2757                url.push_str(query);
2758            }
2759            return Ok(url);
2760        }
2761
2762        let mut url = config.base_url.clone();
2763
2764        if let Some(path) = exchange
2765            .input
2766            .header("CamelHttpPath")
2767            .and_then(|v| v.as_str())
2768        {
2769            if !url.ends_with('/') && !path.starts_with('/') {
2770                url.push('/');
2771            }
2772            url.push_str(path);
2773        }
2774
2775        if let Some(query) = exchange
2776            .input
2777            .header("CamelHttpQuery")
2778            .and_then(|v| v.as_str())
2779        {
2780            // Compose: the endpoint query (raw-preserving,
2781            // consumed-option-filtered) comes first and wins collisions;
2782            // header pairs append verbatim for absent keys (ADR-0071).
2783            // An empty header leaves the endpoint query unchanged.
2784            if let Some(merged) =
2785                merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2786            {
2787                url.push('?');
2788                url.push_str(&merged);
2789            }
2790            return Ok(url);
2791        }
2792
2793        if let Some(query) = resolve_endpoint_query(config)? {
2794            url.push('?');
2795            url.push_str(&query);
2796        }
2797
2798        Ok(url)
2799    }
2800
2801    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2802        status >= range.0 && status <= range.1
2803    }
2804}
2805
2806/// One allowlist entry of the `CamelHttpUri` host fence (`allowedUriHosts`
2807/// endpoint option). DNS hosts are stored ASCII-lowercased; IPv6 literals
2808/// in bracketed canonical form (the `url` crate's host serialization). A
2809/// `port` of `None` is a host-only entry and permits any port.
2810#[derive(Clone, Debug, PartialEq, Eq)]
2811pub struct AllowedUriHost {
2812    /// Canonical host: lowercased DNS name or bracketed IPv6 literal.
2813    pub host: String,
2814    /// `Some` pins the entry to one effective port; `None` permits any.
2815    pub port: Option<u16>,
2816}
2817
2818/// Parse the `allowedUriHosts` option value: comma-separated `host` or
2819/// `host:port` entries. Bracketed IPv6 is supported (`[::1]:8443`, bare
2820/// `[::1]` host-only). Empty segments are dropped. Segments are parsed
2821/// through the `url` crate (with an `http://` scheme injected) so DNS
2822/// names are lowercased and ports range-checked; anything it rejects is a
2823/// malformed entry. A value yielding zero valid entries is also an error.
2824/// Both failure modes fail endpoint creation (fail-closed).
2825fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
2826    let mut entries = Vec::new();
2827    for segment in raw.split(',') {
2828        let segment = segment.trim();
2829        if segment.is_empty() {
2830            continue;
2831        }
2832        let parsed = url::Url::parse(&format!("http://{segment}"))
2833            .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
2834        // A segment carrying a path or userinfo is a typo'd entry — the
2835        // spec's "any other malformed entry" clause. Silently narrowing it
2836        // to its hostname would widen or skew the fence.
2837        if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
2838            return Err(invalid_allowed_uri_host_entry(segment));
2839        }
2840        let Some(host) = parsed.host_str() else {
2841            return Err(invalid_allowed_uri_host_entry(segment));
2842        };
2843        entries.push(AllowedUriHost {
2844            host: host.to_string(),
2845            port: parsed.port(),
2846        });
2847    }
2848    if entries.is_empty() {
2849        return Err(CamelError::InvalidUri(
2850            "allowedUriHosts declares no valid host entries".to_string(),
2851        ));
2852    }
2853    Ok(entries)
2854}
2855
2856fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
2857    CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
2858}
2859
2860/// Whether `url_str` matches the fence. Parse failure or a host-less URL
2861/// is fail-closed (`Ok(false)`). DNS hosts compare case-insensitively
2862/// (both sides are lowercased by the `url` crate); IPv6 compares in
2863/// bracketed canonical form. A host-only entry permits any port; a
2864/// `host:port` entry matches only the effective port — the explicit port
2865/// or the scheme default (443 for https, 80 for http).
2866pub(crate) fn uri_host_allowed(
2867    url_str: &str,
2868    fence: &[AllowedUriHost],
2869) -> Result<bool, CamelError> {
2870    let Ok(parsed) = url::Url::parse(url_str) else {
2871        return Ok(false);
2872    };
2873    let Some(host) = parsed.host_str() else {
2874        return Ok(false);
2875    };
2876    let effective_port = parsed.port().or(match parsed.scheme() {
2877        "https" => Some(443_u16),
2878        "http" => Some(80),
2879        _ => None,
2880    });
2881    Ok(fence.iter().any(|entry| {
2882        entry.host == host
2883            && match entry.port {
2884                None => true,
2885                Some(port) => effective_port == Some(port),
2886            }
2887    }))
2888}
2889
2890/// Serialize the outbound query for the endpoint base.
2891///
2892/// Authored raw pairs come first, byte-for-byte minus consumed option keys
2893/// (order, separators and authored escapes — including `RAW(...)` text —
2894/// preserved); then programmatic `query_params` entries whose key is absent
2895/// from the authored pairs, in declaration order with minimal RFC-3986
2896/// encoding (`%20`, never `+`). Authored keys always win — no duplication,
2897/// no override.
2898///
2899/// Returns `Ok(None)` when no query component is emitted: no pairs at all,
2900/// or a non-empty raw query whose every pair was consumed. A bare `?`
2901/// marker (`raw_query == Some("")`) always emits the query component.
2902fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
2903    let mut parts: Vec<String> = Vec::new();
2904    let mut authored_keys = std::collections::HashSet::new();
2905
2906    if let Some(raw) = config.raw_query.as_deref() {
2907        for (key, span) in raw_query_pairs(raw)? {
2908            authored_keys.insert(key.clone());
2909            if is_consumed_option(&key) {
2910                continue;
2911            }
2912            validate_raw_query_span(span)?;
2913            parts.push(span.to_string());
2914        }
2915    }
2916
2917    for (key, value) in &config.query_params {
2918        if !authored_keys.contains(key.as_str()) {
2919            parts.push(format!(
2920                "{}={}",
2921                encode_query_component(key),
2922                encode_query_component(value)
2923            ));
2924        }
2925    }
2926
2927    if parts.is_empty() && config.raw_query.as_deref() != Some("") {
2928        return Ok(None);
2929    }
2930    Ok(Some(parts.join("&")))
2931}
2932
2933/// Compose the outbound query when a `CamelHttpQuery` exchange header is
2934/// present (ADR-0071). `higher_precedence` — the endpoint query in the
2935/// base arm, the override URI's own query in the override arm — comes
2936/// first and wins any key collision; header pairs append verbatim for
2937/// absent keys only. An empty header leaves the higher-precedence query
2938/// unchanged (no additional `?` marker). Header spans are validated, not
2939/// re-encoded: a byte forbidden in a query component is a resolve error
2940/// naming the byte (Wave-A law).
2941fn merge_header_query(
2942    higher_precedence: Option<&str>,
2943    header_query: &str,
2944) -> Result<Option<String>, CamelError> {
2945    if header_query.is_empty() {
2946        return Ok(higher_precedence.map(str::to_string));
2947    }
2948    let mut parts: Vec<String> = Vec::new();
2949    let mut higher_keys = std::collections::HashSet::new();
2950    for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
2951        higher_keys.insert(key);
2952        parts.push(span.to_string());
2953    }
2954    for (key, span) in raw_query_pairs(header_query)? {
2955        validate_raw_query_span(span)?;
2956        if !higher_keys.contains(key.as_str()) {
2957            parts.push(span.to_string());
2958        }
2959    }
2960    if parts.is_empty() {
2961        return Ok(None);
2962    }
2963    Ok(Some(parts.join("&")))
2964}
2965
2966/// Bytes that may appear unescaped in a URI query component. RFC 3986
2967/// (`query = *( pchar / "/" / "?" )`) admits unreserved, sub-delims, `:`,
2968/// `@`, `/`, `?`, and `%` — with ONE deliberate exclusion from the RFC set:
2969/// the apostrophe (`'`, 0x27). reqwest's WHATWG URL parser re-encodes 0x27
2970/// to `%27` in the special-query percent-encode set (http/https), so an
2971/// authored apostrophe can never ride the wire verbatim; admitting it would
2972/// silently normalize authored bytes (rc-nmupb). Authors write `%27`
2973/// explicitly when they mean the byte on the wire. The WHATWG set's other
2974/// extras (`"`, `` ` ``, `<`, `>`) are already rejected here — they are not
2975/// RFC 3986 query-legal bytes, so no special exclusion is needed for them.
2976fn is_legal_query_byte(byte: u8) -> bool {
2977    matches!(byte,
2978        b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
2979        | b'-' | b'.' | b'_' | b'~'
2980        | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
2981        | b':' | b'@' | b'/' | b'?'
2982        | b'%')
2983}
2984
2985/// Reject an authored raw pair carrying a byte that is not legal in a query
2986/// component (e.g. literal space, `#`, non-ASCII). The serializer never
2987/// silently re-encodes operator-authored bytes: "byte-for-byte" is bounded
2988/// to wire-legal bytes, and the check fires before the resolved string
2989/// reaches any consumer (SSRF pre-check, diagnostics redaction).
2990fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
2991    for &byte in span.as_bytes() {
2992        if !is_legal_query_byte(byte) {
2993            return Err(CamelError::ProcessorError(format!(
2994                "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
2995            )));
2996        }
2997    }
2998    Ok(())
2999}
3000
3001/// Minimal RFC-3986 percent-encoding for one programmatic query component:
3002/// unreserved bytes pass through, every other byte encodes as uppercase
3003/// hex. A space encodes as `%20`, never `+`.
3004fn encode_query_component(component: &str) -> String {
3005    const HEX: &[u8; 16] = b"0123456789ABCDEF";
3006    let mut out = String::with_capacity(component.len());
3007    for &byte in component.as_bytes() {
3008        match byte {
3009            b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
3010                out.push(byte as char);
3011            }
3012            _ => {
3013                out.push('%');
3014                out.push(HEX[(byte >> 4) as usize] as char);
3015                out.push(HEX[(byte & 0x0f) as usize] as char);
3016            }
3017        }
3018    }
3019    out
3020}
3021
3022/// Redact credentials from a URL before it reaches logs or error values
3023/// (ADR-0051 redact-by-construction). Masks userinfo (`user:pass@`) and
3024/// the query string (which commonly carries API keys/tokens). Host and
3025/// path stay visible for diagnosability. Fragments are never echoed: a
3026/// fragment (OAuth2 callback tokens such as `#access_token=...`) is
3027/// dropped and replaced with the `#[redacted]` sentinel in both the
3028/// parsed arm and the unparseable arm. Fail-closed: when the parse fails
3029/// and any authority window contains `@`, only the `[redacted]`
3030/// sentinel is returned. Every authority window is scanned: windows are
3031/// enumerated over maximal runs of `/` and `\` — pure-slash runs of two
3032/// or more characters, backslash-bearing runs only behind an RFC 3986
3033/// scheme prefix (see [`camel_api::redact`] for the canonical window
3034/// rule) — each window starts immediately after the run (so evaders like
3035/// `scheme:////user:pass@evil/` cannot hide a `@` behind a slash run)
3036/// and ends at the next `/`, `?`, or `#`; scanning all windows keeps
3037/// later `//user:pass@` substrings from hiding behind a benign first
3038/// window.
3039///
3040/// The parsed arm keeps `url::Url::parse` (the authority can only be
3041/// judged by the parser) and masks the real authority accessors, then
3042/// delegates wholesale to the canonical string surgery in
3043/// [`camel_api::redact::redact_url`]: rust-url can park later-window
3044/// userinfo bytes in the path (`https://h//user:pass@evil/`), and the
3045/// canonical helper owns window masking, `?`/`#` sentinel composition
3046/// (one per distinct introducer, first-occurrence order), and the
3047/// 256-byte UTF-8 cap. The unparseable arm delegates to
3048/// [`camel_api::redact::redact_url_fail_closed`].
3049pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
3050    match url::Url::parse(raw) {
3051        Ok(mut u) => {
3052            // Fail closed when an authority marker was accepted but no
3053            // host was stored: userinfo-shaped bytes can hide in the path
3054            // behind the marker, and empty-host schemes (`file:///us@r/x`,
3055            // `unix:///@socket`) can put a `@` in that window too. Such
3056            // inputs are sentineled wholesale — deliberate fail-closed
3057            // over-redaction per ADR-0051.
3058            if !u.cannot_be_a_base()
3059                && u.host_str().is_none()
3060                && camel_api::redact::window_has_at_sign(raw)
3061            {
3062                return "[redacted]".to_string();
3063            }
3064            if !u.username().is_empty() || u.password().is_some() {
3065                let _ = u.set_username("***");
3066                let _ = u.set_password(None);
3067            }
3068            // Query and fragment stay on the rendered URL; the canonical
3069            // redactor drops them and composes the sentinels.
3070            let s = u.to_string();
3071            camel_api::redact::redact_url(&s)
3072        }
3073        Err(_) => camel_api::redact::redact_url_fail_closed(raw),
3074    }
3075}
3076
3077/// Maximum bytes of an upstream error response body embedded into
3078/// `CamelError::HttpOperationFailed`. The body is attacker-controllable (a
3079/// malicious or compromised upstream), so it is truncated and lossy-decoded to
3080/// bound log injection / DLQ payload size.
3081const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3082
3083fn truncate_error_body(body: &[u8]) -> String {
3084    if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3085        String::from_utf8_lossy(body).into_owned()
3086    } else {
3087        let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3088        s.push_str("...[truncated]");
3089        s
3090    }
3091}
3092
3093impl HttpProducer {
3094    /// Whether the HTTP method is entity-enclosing (may carry a request
3095    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
3096    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
3097    /// §9.3.1/§9.3.2).
3098    fn is_entity_enclosing(method: &str) -> bool {
3099        matches!(method, "POST" | "PUT" | "PATCH")
3100    }
3101}
3102
3103impl Service<Exchange> for HttpProducer {
3104    type Response = Exchange;
3105    type Error = CamelError;
3106    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3107
3108    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3109        Poll::Ready(Ok(()))
3110    }
3111
3112    fn call(&mut self, exchange: Exchange) -> Self::Future {
3113        let config = self.config.clone();
3114        let shared_client = self.client.clone();
3115        let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3116        let http_config = self.http_config.clone();
3117        let component_metrics = self.runtime.component_metrics();
3118
3119        Box::pin(async move {
3120            let mut exchange = exchange;
3121            let outcome = async {
3122                let method_str = HttpProducer::resolve_method(&exchange, &config);
3123                // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
3124                // and PATCH may carry a request body. Any other resolved method
3125                // drops the exchange body before the request is built (Apache
3126                // Camel `HttpMethods` parity).
3127                let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3128                let url = HttpProducer::resolve_url(&exchange, &config)?;
3129
3130                // SECURITY: Validate URL for SSRF
3131                ssrf::validate_url_for_ssrf(&url, &config)?;
3132
3133                // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
3134                // (L-H2). When the URL uses a domain name and SSRF protection is active,
3135                // reuse the endpoint's cached DNS-pinned client for that validated
3136                // (host, addrs) pair — built once with resolve_to_addrs, then shared so
3137                // repeated requests keep one connection pool without re-resolving DNS.
3138                // Per-request SSRF validation and DNS pinning are unchanged. IP-literal
3139                // URLs use the endpoint's unpinned shared client.
3140                let resolved =
3141                    ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
3142                let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3143                    pinned_cache
3144                        .get_or_build(host.as_str(), addrs, || {
3145                            build_client(&http_config, Some((host.as_str(), addrs)))
3146                        })
3147                        .await
3148                } else {
3149                    shared_client.clone()
3150                };
3151
3152                debug!(
3153                    correlation_id = %exchange.correlation_id(),
3154                    method = %method_str,
3155                    url = %redact_url_for_diagnostics(&url),
3156                    "HTTP request"
3157                );
3158
3159                let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3160                    CamelError::ProcessorError(format!(
3161                        "Invalid HTTP method '{}': {}",
3162                        method_str, e
3163                    ))
3164                })?;
3165
3166                // Collect headers for potential redirect replay
3167                let mut collected_headers: Vec<(
3168                    reqwest::header::HeaderName,
3169                    reqwest::header::HeaderValue,
3170                )> = Vec::new();
3171
3172                if let Some(user_agent) = &config.user_agent
3173                    && !config.bridge_endpoint
3174                {
3175                    match constructed_header("user-agent", user_agent) {
3176                        Ok((_, val)) => {
3177                            collected_headers.push((reqwest::header::USER_AGENT, val));
3178                        }
3179                        Err(drop) => debug!(
3180                            correlation_id = %exchange.correlation_id(),
3181                            header = %drop.name,
3182                            "outbound header dropped: {}",
3183                            drop.reason
3184                        ),
3185                    }
3186                }
3187
3188                // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
3189                #[cfg(feature = "otel")]
3190                let should_inject_otel = !config.bridge_endpoint;
3191                #[cfg(feature = "otel")]
3192                if should_inject_otel {
3193                    let mut otel_headers = HashMap::new();
3194                    camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3195                    for (k, v) in otel_headers {
3196                        match constructed_header(&k, &v) {
3197                            Ok((name, val)) => collected_headers.push((name, val)),
3198                            Err(drop) => debug!(
3199                                correlation_id = %exchange.correlation_id(),
3200                                header = %drop.name,
3201                                "outbound header dropped: {}",
3202                                drop.reason
3203                            ),
3204                        }
3205                    }
3206                }
3207
3208                let conn_tokens = header_policy::connection_tokens(
3209                    exchange
3210                        .input
3211                        .headers
3212                        .iter()
3213                        .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3214                        .filter_map(|(_, v)| v.as_str()),
3215                );
3216
3217                let outbound = select_outbound_headers(
3218                    &exchange.input.headers,
3219                    &config.skip_request_headers,
3220                    &conn_tokens,
3221                );
3222                for drop in &outbound.drops {
3223                    if let Some(value_kind) = drop.value_kind {
3224                        debug!(
3225                            correlation_id = %exchange.correlation_id(),
3226                            header = %drop.name,
3227                            value_kind = value_kind,
3228                            "outbound header dropped: {}",
3229                            drop.reason
3230                        );
3231                    } else {
3232                        debug!(
3233                            correlation_id = %exchange.correlation_id(),
3234                            header = %drop.name,
3235                            "outbound header dropped: {}",
3236                            drop.reason
3237                        );
3238                    }
3239                }
3240                collected_headers.extend(outbound.accepted);
3241
3242                // Auth headers
3243                if !config.bridge_endpoint {
3244                    match &config.auth {
3245                        HttpAuth::None => {}
3246                        HttpAuth::Basic { username, password } => {
3247                            use base64::Engine;
3248                            // allow-secret: credentials combined for base64 Basic auth header
3249                            let credentials = format!("{username}:{password}");
3250                            let encoded =
3251                                base64::engine::general_purpose::STANDARD.encode(credentials);
3252                            // Base64 output is always header-safe; the guard is kept
3253                            // for uniformity with Bearer.
3254                            match constructed_header("authorization", &format!("Basic {encoded}")) {
3255                                Ok((_, val)) => {
3256                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3257                                }
3258                                Err(drop) => debug!(
3259                                    correlation_id = %exchange.correlation_id(),
3260                                    header = %drop.name,
3261                                    "outbound header dropped: {}",
3262                                    drop.reason
3263                                ),
3264                            }
3265                        }
3266                        HttpAuth::Bearer { token } => {
3267                            // allow-secret: Bearer token in Authorization header
3268                            let bearer = format!("Bearer {token}");
3269                            match constructed_header("authorization", &bearer) {
3270                                Ok((_, val)) => {
3271                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3272                                }
3273                                Err(drop) => debug!(
3274                                    correlation_id = %exchange.correlation_id(),
3275                                    header = %drop.name,
3276                                    "outbound header dropped: {}",
3277                                    drop.reason
3278                                ),
3279                            }
3280                        }
3281                    }
3282
3283                    if config.connection_close {
3284                        collected_headers.push((
3285                            reqwest::header::CONNECTION,
3286                            reqwest::header::HeaderValue::from_static("close"),
3287                        ));
3288                    }
3289                }
3290
3291                // Materialize body
3292                let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3293                let materialized_body: Option<Vec<u8>> = if is_stream_body {
3294                    if suppress_body {
3295                        // A stream body dropped under a non-entity-enclosing
3296                        // method always warns (its emptiness is unknowable) and
3297                        // stays consumed (mem::take). The stream attach arm below
3298                        // still runs its outer flag check, but the inner `if let
3299                        // Body::Stream` re-match fails on the now-Empty body, so
3300                        // no stream is attached and no AlreadyConsumed error can
3301                        // fire.
3302                        std::mem::take(&mut exchange.input.body);
3303                        // log-policy: handler-owned
3304                        tracing::warn!(
3305                            correlation_id = %exchange.correlation_id(),
3306                            method = %method_str,
3307                            "dropping request body for non-entity-enclosing HTTP method"
3308                        );
3309                    }
3310                    None // Streams can't be replayed on redirect
3311                } else {
3312                    let body = std::mem::take(&mut exchange.input.body);
3313                    let bytes = body.into_bytes(config.max_body_size).await?;
3314                    if bytes.is_empty() {
3315                        // Empty body: nothing to send and nothing to warn about.
3316                        None
3317                    } else if suppress_body {
3318                        // log-policy: handler-owned
3319                        tracing::warn!(
3320                            correlation_id = %exchange.correlation_id(),
3321                            method = %method_str,
3322                            "dropping request body for non-entity-enclosing HTTP method"
3323                        );
3324                        None
3325                    } else {
3326                        Some(bytes.to_vec())
3327                    }
3328                };
3329
3330                let response = if config.follow_redirects && !is_stream_body {
3331                    // Use manual redirect loop with per-hop SSRF validation.
3332                    // `client` is the pinned-or-shared binding for the initial
3333                    // request (a hostname initial request keeps its DNS-pinned
3334                    // client); `shared_client` is the unpinned endpoint client
3335                    // reused by IP-literal redirect hops.
3336                    ssrf::send_with_ssrf_safe_redirects(
3337                        &client,
3338                        &shared_client,
3339                        &pinned_cache,
3340                        &http_config,
3341                        &config,
3342                        method,
3343                        &url,
3344                        collected_headers,
3345                        materialized_body,
3346                        config.max_redirects,
3347                        config.response_timeout,
3348                    )
3349                    .await?
3350                } else {
3351                    // Direct send (no redirect following, or streaming body)
3352                    let mut request = client.request(method, &url);
3353
3354                    if let Some(timeout) = config.response_timeout {
3355                        request = request.timeout(timeout);
3356                    }
3357
3358                    for (name, value) in &collected_headers {
3359                        request = request.header(name, value);
3360                    }
3361
3362                    if is_stream_body {
3363                        if let Body::Stream(ref s) = exchange.input.body {
3364                            let mut stream_lock = s.stream.lock().await;
3365                            if let Some(stream) = stream_lock.take() {
3366                                request = request.body(reqwest::Body::wrap_stream(stream));
3367                            } else {
3368                                return Err(CamelError::AlreadyConsumed);
3369                            }
3370                        }
3371                    } else if let Some(ref body_bytes) = materialized_body {
3372                        request = request.body(body_bytes.clone());
3373                    }
3374
3375                    request.send().await.map_err(|e| {
3376                        CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3377                    })?
3378                };
3379
3380                let status_code = response.status().as_u16();
3381                let status_text = response
3382                    .status()
3383                    .canonical_reason()
3384                    .unwrap_or("Unknown")
3385                    .to_string();
3386
3387                for (key, value) in response.headers() {
3388                    if config
3389                        .skip_response_headers
3390                        .iter()
3391                        .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3392                    {
3393                        continue;
3394                    }
3395                    if let Ok(val_str) = value.to_str() {
3396                        exchange.input.set_header(
3397                            title_case_header(key.as_str()),
3398                            serde_json::Value::String(val_str.to_string()),
3399                        );
3400                    }
3401                }
3402
3403                exchange.input.set_header(
3404                    "CamelHttpResponseCode",
3405                    serde_json::Value::Number(status_code.into()),
3406                );
3407                exchange.input.set_header(
3408                    "CamelHttpResponseText",
3409                    serde_json::Value::String(status_text.clone()),
3410                );
3411
3412                // Read response body with timeout and size guard (HTTP-004, HTTP-005)
3413                let read_timeout = Duration::from_millis(config.read_timeout_ms);
3414                let response_body = tokio::time::timeout(read_timeout, async {
3415                    // Check Content-Length header before allocating
3416                    if let Some(content_len) = response.content_length()
3417                        && content_len > config.max_response_bytes as u64
3418                    {
3419                        return Err(CamelError::ProcessorError(format!(
3420                            "Response body too large: {} bytes exceeds limit of {} bytes",
3421                            content_len, config.max_response_bytes
3422                        )));
3423                    }
3424                    // Use bytes_stream() for lazy streaming with size guard
3425                    use futures::TryStreamExt;
3426                    let mut stream = response.bytes_stream();
3427                    let mut total: usize = 0;
3428                    let mut collected = Vec::new();
3429                    while let Some(chunk) = stream.try_next().await.map_err(|e| {
3430                        CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3431                    })? {
3432                        total += chunk.len();
3433                        if total > config.max_response_bytes {
3434                            return Err(CamelError::ProcessorError(format!(
3435                                "Response body too large: {} bytes exceeds limit of {} bytes",
3436                                total, config.max_response_bytes
3437                            )));
3438                        }
3439                        collected.push(chunk);
3440                    }
3441                    let mut result = bytes::BytesMut::with_capacity(total);
3442                    for chunk in collected {
3443                        result.extend_from_slice(&chunk);
3444                    }
3445                    Ok::<bytes::Bytes, CamelError>(result.freeze())
3446                })
3447                .await
3448                .map_err(|_| {
3449                    CamelError::ProcessorError(format!(
3450                        "Read timeout after {}ms",
3451                        config.read_timeout_ms
3452                    ))
3453                })??;
3454
3455                if config.throw_exception_on_failure
3456                    && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3457                {
3458                    return Err(CamelError::HttpOperationFailed {
3459                        method: method_str,
3460                        // ADR-0051 redact-by-construction: never embed
3461                        // userinfo/query credentials in the error value.
3462                        url: redact_url_for_diagnostics(&url),
3463                        status_code,
3464                        status_text,
3465                        response_body: Some(truncate_error_body(&response_body)),
3466                    });
3467                }
3468
3469                if !response_body.is_empty() {
3470                    exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3471                }
3472
3473                debug!(
3474                    correlation_id = %exchange.correlation_id(),
3475                    status = status_code,
3476                    url = %redact_url_for_diagnostics(&url),
3477                    "HTTP response"
3478                );
3479                Ok(exchange)
3480            }
3481            .await;
3482            // ("http","request") facade (dashboard-observability 4.3): the
3483            // request boundary is the full client round-trip — SSRF checks,
3484            // send, response read, and (with throwExceptionOnFailure) the
3485            // status gate. http runs no retry_async and the producer
3486            // previously emitted nothing, so no label collides with
3487            // e:http:request.
3488            component_metrics.observe("http", "request", outcome.is_err());
3489            outcome
3490        })
3491    }
3492}
3493
3494/// Serializes tests that mutate or depend on the global `ServerRegistry`.
3495///
3496/// `ServerRegistry::global()` is a process-wide singleton that persists
3497/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
3498/// with another test that has a live server on a fixed port (e.g. 9991),
3499/// the registry entry is removed while the OS socket is still bound, so
3500/// the next `get_or_spawn` call on that port fails with "Address already
3501/// in use". This mutex does not give blanket protection by itself. It
3502/// helps only where every participant follows the mutex law: the
3503/// consumer-test readiness helper holds it from `stage_listener` until
3504/// readiness-complete (http-test-harness spec, requirement
3505/// "Registry-mutation serialization during setup"), and each `reset()`
3506/// caller takes it before the reset.
3507#[cfg(test)]
3508pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3509
3510/// Poison-recovering acquire of REGISTRY_TEST_MUTEX (httpflake).
3511///
3512/// The mutex guards test SERIALIZATION only - the registry own data is
3513/// protected by its inner lock - so a sibling test that panics while
3514/// holding the guard must not poison the mutex and cascade failures
3515/// into every other holder. Recovery via into_inner is therefore safe
3516/// and keeps one failing test failing as ONE test.
3517#[cfg(test)]
3518pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3519    REGISTRY_TEST_MUTEX
3520        .lock()
3521        .unwrap_or_else(|poisoned| poisoned.into_inner())
3522}
3523
3524/// Map a pipeline error to an HTTP reply.
3525///
3526/// Extracted from the inline `match` in `dispatch_handler` for unit
3527/// testability (rc-1dk4). Client-fault errors map to their 4xx codes
3528/// with a structured JSON error body: `TypeConversionFailed`/
3529/// `ValidationError` → 400, `UnsupportedMediaType` → 415 (media
3530/// negotiation gate, REST lowering), `NotAcceptable` → 406 (same
3531/// gate); `Unauthenticated`/`Unauthorized` keep their `401`/`403`
3532/// mappings; all other errors map to `500 Internal Server Error`.
3533fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3534    match e {
3535        CamelError::Unauthenticated(msg) => {
3536            tracing::warn!(error = %msg, path = %path, "Authentication failed");
3537            HttpReply {
3538                status: 401,
3539                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3540                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3541            }
3542        }
3543        CamelError::Unauthorized(msg) => {
3544            tracing::warn!(error = %msg, path = %path, "Authorization failed");
3545            HttpReply {
3546                status: 403,
3547                headers: vec![],
3548                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3549            }
3550        }
3551        CamelError::TypeConversionFailed(msg) => {
3552            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3553            json_error_reply(400, "bad_request", msg)
3554        }
3555        CamelError::ValidationError(msg) => {
3556            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3557            json_error_reply(400, "validation_error", msg)
3558        }
3559        CamelError::ConsumerStopping => {
3560            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3561            HttpReply {
3562                status: 503,
3563                headers: vec![],
3564                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3565            }
3566        }
3567        CamelError::UnsupportedMediaType { consumed, declared } => {
3568            tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3569            json_error_reply(
3570                415,
3571                "unsupported_media_type",
3572                format!("consumed {consumed}, declared {declared}"),
3573            )
3574        }
3575        CamelError::NotAcceptable { accept, produced } => {
3576            tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3577            json_error_reply(
3578                406,
3579                "not_acceptable",
3580                format!("accept {accept}, produced {produced}"),
3581            )
3582        }
3583        e => {
3584            // log-policy: handler-owned
3585            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3586            HttpReply {
3587                status: 500,
3588                headers: vec![],
3589                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3590            }
3591        }
3592    }
3593}
3594
3595/// Build a JSON error reply with the given status, error code, and message.
3596///
3597/// Shared by the `TypeConversionFailed`/`ValidationError` (400),
3598/// `UnsupportedMediaType` (415), and `NotAcceptable` (406) arms of
3599/// `pipeline_error_to_reply` so the four replies cannot drift apart. The
3600/// `unwrap_or_else(|_| "{}".to_string())` fallback keeps the reply valid
3601/// JSON even if serialization fails.
3602fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3603    let body = serde_json::to_string(&serde_json::json!({
3604        "error": code,
3605        "message": message,
3606    }))
3607    .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3608    HttpReply {
3609        status,
3610        headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3611        body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3612    }
3613}
3614
3615/// Lowercase kind name for a JSON value, used in drop diagnostics so log
3616/// readers see *why* a header had no scalar string form without the value
3617/// itself ever entering diagnostics.
3618const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3619    match v {
3620        serde_json::Value::Null => "null",
3621        serde_json::Value::Bool(_) => "bool",
3622        serde_json::Value::Number(_) => "number",
3623        serde_json::Value::String(_) => "string",
3624        serde_json::Value::Array(_) => "array",
3625        serde_json::Value::Object(_) => "object",
3626    }
3627}
3628
3629/// Scalar string form of a JSON value: strings pass through, `Number` and
3630/// `Bool` are stringified, everything else has no single-value form.
3631/// Shared by the consumer reply finaliser and the producer outbound filter
3632/// so the two directions cannot drift apart (rc-lidtk / rc-8l23a).
3633fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3634    match v {
3635        serde_json::Value::String(s) => Some(s.clone()),
3636        serde_json::Value::Number(n) => Some(n.to_string()),
3637        serde_json::Value::Bool(b) => Some(b.to_string()),
3638        _ => None,
3639    }
3640}
3641
3642/// Select the HTTP response headers emitted by the consumer reply finaliser
3643/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
3644/// `dispatch_handler` for unit testability.
3645///
3646/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
3647/// server-owned headers, plus `content-length`/`content-type` (re-derived),
3648/// and any header named by a `Connection` token. Scalar non-string values
3649/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3650/// the wire instead of being silently discarded (rc-lidtk); `null`, objects,
3651/// and arrays have no single-value form and are dropped. Every drop is
3652/// logged at DEBUG with the header name and reason — names only, never
3653/// values, so credentials cannot leak into diagnostics (ADR-0051).
3654/// Appends a single `Content-Type` from `user_content_type` falling back to
3655/// `inferred_content_type` when either is present.
3656fn select_response_headers(
3657    headers: &HashMap<String, serde_json::Value>,
3658    user_content_type: Option<String>,
3659    inferred_content_type: Option<String>,
3660) -> Vec<(String, String)> {
3661    let conn_tokens = header_policy::connection_tokens(
3662        headers
3663            .iter()
3664            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3665            .filter_map(|(_, v)| v.as_str()),
3666    );
3667    let mut selected: Vec<(String, String)> = Vec::new();
3668    for (k, v) in headers {
3669        if k.starts_with("Camel") {
3670            debug!(header = %k, "reply header dropped: Camel namespace");
3671            continue;
3672        }
3673        if header_policy::excluded_response(k, &conn_tokens) {
3674            debug!(header = %k, "reply header dropped: emission policy");
3675            continue;
3676        }
3677        match scalar_string_form(v) {
3678            Some(s) => selected.push((k.clone(), s)),
3679            None => debug!(
3680                header = %k,
3681                value_kind = json_value_kind(v),
3682                "reply header dropped: no scalar string form"
3683            ),
3684        }
3685    }
3686    if let Some(ct) = user_content_type.or(inferred_content_type) {
3687        selected.push(("Content-Type".to_string(), ct));
3688    }
3689    selected
3690}
3691
3692/// One outbound header drop: the exchange header name, a stable reason
3693/// string, and — when the drop was caused by the value having no scalar
3694/// string form — the JSON value kind. Names and kinds only, never values
3695/// (ADR-0051).
3696#[derive(Debug)]
3697struct OutboundHeaderDrop<'a> {
3698    name: &'a str,
3699    reason: &'static str,
3700    value_kind: Option<&'static str>,
3701}
3702
3703/// Outbound exchange-header selection result: headers accepted for the
3704/// wire plus drop records for call-site DEBUG logging.
3705struct OutboundHeaderSelection<'a> {
3706    accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3707    drops: Vec<OutboundHeaderDrop<'a>>,
3708}
3709
3710/// Select the exchange headers the HTTP producer forwards on the outbound
3711/// request (ADR-0057 / rc-8l23a). Extracted from the inline filter in
3712/// `HttpProducer::call` for unit testability.
3713///
3714/// Drops `Camel`-namespace headers, names listed in `skip_request_headers`,
3715/// hop-by-hop/framing and connection-token-named headers excluded by the
3716/// outbound emission policy, and headers whose name or stringified value
3717/// fails `HeaderName`/`HeaderValue` construction. Scalar non-string values
3718/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3719/// the wire instead of being silently discarded (rc-8l23a); `null`, objects,
3720/// and arrays have no single-value form and are dropped. Drops are returned
3721/// rather than logged so the call site can attach the correlation id; log
3722/// consumers see names and kinds only, never values (ADR-0051).
3723fn select_outbound_headers<'a>(
3724    headers: &'a HashMap<String, serde_json::Value>,
3725    skip_request_headers: &[String],
3726    conn_tokens: &[String],
3727) -> OutboundHeaderSelection<'a> {
3728    let mut accepted = Vec::new();
3729    let mut drops = Vec::new();
3730    for (key, value) in headers {
3731        if key.starts_with("Camel") {
3732            drops.push(OutboundHeaderDrop {
3733                name: key,
3734                reason: "Camel namespace",
3735                value_kind: None,
3736            });
3737            continue;
3738        }
3739        if skip_request_headers
3740            .iter()
3741            .any(|h| h.eq_ignore_ascii_case(key))
3742        {
3743            drops.push(OutboundHeaderDrop {
3744                name: key,
3745                reason: "skip_request_headers",
3746                value_kind: None,
3747            });
3748            continue;
3749        }
3750        if header_policy::excluded_outbound(key, conn_tokens) {
3751            drops.push(OutboundHeaderDrop {
3752                name: key,
3753                reason: "outbound emission policy",
3754                value_kind: None,
3755            });
3756            continue;
3757        }
3758        let Some(val_str) = scalar_string_form(value) else {
3759            drops.push(OutboundHeaderDrop {
3760                name: key,
3761                reason: "no scalar string form",
3762                value_kind: Some(json_value_kind(value)),
3763            });
3764            continue;
3765        };
3766        match constructed_header(key, &val_str) {
3767            Ok((name, val)) => accepted.push((name, val)),
3768            Err(drop) => drops.push(drop),
3769        }
3770    }
3771    OutboundHeaderSelection { accepted, drops }
3772}
3773
3774/// Construct a wire-ready `(HeaderName, HeaderValue)` pair for one outbound
3775/// header, or a drop record when the name or value fails construction
3776/// (rc-jbs1v). Drop records carry name and reason only, never values
3777/// (ADR-0051).
3778fn constructed_header<'a>(
3779    name: &'a str,
3780    value: &str,
3781) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3782    let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3783        Ok(header_name) => header_name,
3784        Err(_) => {
3785            return Err(OutboundHeaderDrop {
3786                name,
3787                reason: "invalid header name",
3788                value_kind: None,
3789            });
3790        }
3791    };
3792    let header_value = match reqwest::header::HeaderValue::from_str(value) {
3793        Ok(header_value) => header_value,
3794        Err(_) => {
3795            return Err(OutboundHeaderDrop {
3796                name,
3797                reason: "invalid header value",
3798                value_kind: None,
3799            });
3800        }
3801    };
3802    Ok((header_name, header_value))
3803}
3804
3805#[cfg(test)]
3806mod tests {
3807    use camel_component_api::test_support::NoopRuntimeObservability;
3808
3809    // Producer/consumer tests drive the component-ops facade on every
3810    // call (dashboard-observability 4.3), so even non-observability tests
3811    // must supply a collector-returning runtime — Noop everywhere.
3812    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3813        std::sync::Arc::new(NoopRuntimeObservability)
3814    }
3815    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3816        std::sync::Arc::new(NoopRuntimeObservability)
3817    }
3818    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3819        std::sync::Arc::new(NoopRuntimeObservability)
3820    }
3821
3822    use super::*;
3823    use crate::config::TlsConfig;
3824    use crate::rest_match::PathSegment;
3825    use camel_component_api::{Message, NoOpComponentContext};
3826    use std::sync::Arc;
3827    use std::time::Duration;
3828
3829    fn test_producer_ctx() -> ProducerContext {
3830        ProducerContext::new()
3831    }
3832
3833    // -----------------------------------------------------------------------
3834    // Security: credential redaction (audit 2026-08-31, finding F3-1)
3835    // -----------------------------------------------------------------------
3836
3837    #[test]
3838    fn redact_url_drops_oauth2_fragment_access_token() {
3839        let redacted =
3840            redact_url_for_diagnostics("https://app.example/cb#access_token=SECRET&state=x");
3841        assert!(
3842            !redacted.contains("SECRET"),
3843            "fragment access token leaked: {redacted}"
3844        );
3845        assert!(
3846            !redacted.contains("access_token"),
3847            "fragment key leaked: {redacted}"
3848        );
3849        assert!(
3850            redacted.ends_with("#[redacted]"),
3851            "fragment must be replaced with the sentinel: {redacted}"
3852        );
3853    }
3854
3855    #[test]
3856    fn redact_url_drops_oauth2_fragment_id_token() {
3857        let redacted =
3858            redact_url_for_diagnostics("https://app.example/cb#id_token=eyJhbG.SECRET.SIG&state=y");
3859        assert!(
3860            !redacted.contains("eyJhbG"),
3861            "id token payload leaked: {redacted}"
3862        );
3863        assert!(
3864            !redacted.contains("id_token"),
3865            "id token key leaked: {redacted}"
3866        );
3867        assert!(
3868            !redacted.contains("SECRET"),
3869            "id token signature leaked: {redacted}"
3870        );
3871        assert!(
3872            redacted.ends_with("#[redacted]"),
3873            "fragment must be replaced with the sentinel: {redacted}"
3874        );
3875    }
3876
3877    #[test]
3878    fn redact_url_drops_generic_fragment_kv() {
3879        let redacted = redact_url_for_diagnostics("https://h.example/p/session#session=abc123");
3880        assert!(
3881            !redacted.contains("abc123"),
3882            "fragment value leaked: {redacted}"
3883        );
3884        assert!(
3885            !redacted.contains("session="),
3886            "fragment key leaked: {redacted}"
3887        );
3888        assert!(
3889            redacted.contains("#[redacted]"),
3890            "fragment must be replaced with the sentinel: {redacted}"
3891        );
3892    }
3893
3894    #[test]
3895    fn redact_url_query_and_fragment_sentinels_compose() {
3896        let redacted = redact_url_for_diagnostics("https://h.example/p?a=1#access_token=x");
3897        assert_eq!(
3898            redacted, "https://h.example/p?[redacted]#[redacted]",
3899            "query and fragment sentinels must compose: {redacted}"
3900        );
3901    }
3902
3903    #[test]
3904    fn redact_url_drops_benign_fragment_too() {
3905        // Fragments never reach the wire, so nothing in them is diagnostic:
3906        // strictest-wins drops benign fragments too.
3907        let redacted = redact_url_for_diagnostics("https://h.example/docs#section-3");
3908        assert_eq!(
3909            redacted, "https://h.example/docs#[redacted]",
3910            "benign fragment must still be dropped: {redacted}"
3911        );
3912    }
3913
3914    #[test]
3915    fn redact_url_unparseable_fragment_credentials_dropped() {
3916        let raw = "ht tps://app.example/cb#access_token=SECRET";
3917        assert!(
3918            url::Url::parse(raw).is_err(),
3919            "fixture must be unparseable: {raw}"
3920        );
3921        let redacted = redact_url_for_diagnostics(raw);
3922        assert!(
3923            !redacted.contains("SECRET"),
3924            "unparseable fragment token leaked: {redacted}"
3925        );
3926        assert!(
3927            !redacted.contains("access_token"),
3928            "unparseable fragment bytes leaked: {redacted}"
3929        );
3930        assert!(
3931            redacted.contains("#[redacted]"),
3932            "unparseable fragment must end in the sentinel: {redacted}"
3933        );
3934    }
3935
3936    #[test]
3937    fn redact_url_double_slash_evader_sentinel() {
3938        // url::Url::parse accepts this (empty host allowed for non-special
3939        // schemes), parking userinfo-shaped bytes in the opaque path.
3940        let redacted = redact_url_for_diagnostics("scheme:////user:pass@evil/");
3941        assert_eq!(
3942            redacted, "[redacted]",
3943            "double-slash evader must fail closed: {redacted}"
3944        );
3945    }
3946
3947    #[test]
3948    fn redact_url_triple_slash_evader_sentinel() {
3949        let redacted = redact_url_for_diagnostics("scheme:///user:pass@evil/");
3950        assert_eq!(
3951            redacted, "[redacted]",
3952            "triple-slash evader must fail closed: {redacted}"
3953        );
3954    }
3955
3956    #[test]
3957    fn redact_url_bare_protocol_relative_userinfo_sentinel() {
3958        let redacted = redact_url_for_diagnostics("//user:pass@evil");
3959        assert_eq!(
3960            redacted, "[redacted]",
3961            "protocol-relative userinfo must fail closed: {redacted}"
3962        );
3963    }
3964
3965    #[test]
3966    fn redact_url_empty_host_userinfo_sentinel() {
3967        // url::Url::parse rejects this with EmptyHost; the failure arm must
3968        // fail closed without panicking on the empty host.
3969        let redacted = redact_url_for_diagnostics("scheme://user@");
3970        assert_eq!(
3971            redacted, "[redacted]",
3972            "empty-host userinfo must fail closed: {redacted}"
3973        );
3974    }
3975
3976    #[test]
3977    fn redact_url_unparseable_slash_run_evader_sentinel() {
3978        // Unlike `scheme:////user:pass@evil/` (parses Ok, host=None, and
3979        // hits the parsed-arm guard), the space in the scheme forces the
3980        // parse to fail, driving the failure arm's slash-run skip directly.
3981        let raw = "schem e:////user:pass@evil/";
3982        assert!(
3983            url::Url::parse(raw).is_err(),
3984            "fixture must be unparseable: {raw}"
3985        );
3986        let redacted = redact_url_for_diagnostics(raw);
3987        assert_eq!(
3988            redacted, "[redacted]",
3989            "unparseable slash-run evader must fail closed: {redacted}"
3990        );
3991    }
3992
3993    #[test]
3994    fn redact_url_unparseable_later_window_userinfo_sentinel() {
3995        // The first `//` window ("ho st") carries no `@`, but a later
3996        // `//user:pass@evil/` window does. The scan must consider every
3997        // `//` window, not just the first, or the credentials echo.
3998        let raw = "http://ho st/a//user:pass@evil/";
3999        assert!(
4000            url::Url::parse(raw).is_err(),
4001            "fixture must be unparseable: {raw}"
4002        );
4003        let redacted = redact_url_for_diagnostics(raw);
4004        assert_eq!(
4005            redacted, "[redacted]",
4006            "userinfo in a later // window must fail closed: {redacted}"
4007        );
4008    }
4009
4010    #[test]
4011    fn redact_url_parsed_later_window_userinfo_masked() {
4012        // rust-url accepts this with host `h` and parks the userinfo bytes
4013        // in the path, so the accessor mask never fires. The parsed arm
4014        // must apply the same window-masking surgery as the string-based
4015        // redactors or the later window renders verbatim.
4016        let redacted = redact_url_for_diagnostics("https://h//user:pass@evil/");
4017        assert!(
4018            !redacted.contains("user:pass"),
4019            "parsed later-window userinfo leaked: {redacted}"
4020        );
4021        assert!(
4022            redacted.contains("h//***@evil/"),
4023            "later window must be masked in place: {redacted}"
4024        );
4025    }
4026
4027    #[test]
4028    fn redact_url_parsed_window_mask_idempotent_with_real_userinfo() {
4029        // Real userinfo is masked by the accessor step; the window surgery
4030        // on the rendered string must not double-mask it (`***@h` stays),
4031        // and the later `x@y` path window must still be masked.
4032        let redacted = redact_url_for_diagnostics("https://user:pass@h//x@y/");
4033        assert!(
4034            redacted.contains("***@h"),
4035            "accessor mask must survive the window surgery: {redacted}"
4036        );
4037        assert!(
4038            !redacted.contains("user:pass"),
4039            "real userinfo leaked: {redacted}"
4040        );
4041        assert!(
4042            !redacted.contains("x@y"),
4043            "later path window leaked: {redacted}"
4044        );
4045    }
4046
4047    #[test]
4048    fn redact_url_backslash_authority_ruling() {
4049        // Probe outcome: url::Url::parse accepts this input. http is a
4050        // special scheme, so backslashes normalize to slashes and the
4051        // credentials land in real userinfo
4052        // (`http://user:pass@evil/path`). The parsed arm must mask them
4053        // like any other userinfo.
4054        let redacted = redact_url_for_diagnostics("http:\\\\user:pass@evil\\path");
4055        assert!(
4056            redacted.contains("***@"),
4057            "backslash authority must be userinfo-masked: {redacted}"
4058        );
4059        assert!(
4060            !redacted.contains("user:pass"),
4061            "backslash authority must not leak credentials: {redacted}"
4062        );
4063    }
4064
4065    #[test]
4066    fn non_special_backslash_authority_masked() {
4067        // Non-special scheme: the url crate does not normalize the
4068        // backslashes, so the string carries no `//` run — the
4069        // scheme-prefixed backslash window must still suppress the
4070        // credentials.
4071        let redacted = redact_url_for_diagnostics("foo:\\user:pass@evil/");
4072        assert!(
4073            !redacted.contains("user:pass"),
4074            "non-special backslash authority leaked: {redacted}"
4075        );
4076        assert!(
4077            !redacted.contains("pass"),
4078            "non-special backslash authority leaked a credential byte: {redacted}"
4079        );
4080        // Clean sibling stays visible (spec scenario's second given).
4081        assert_eq!(
4082            redact_url_for_diagnostics("foo:\\clean/path"),
4083            "foo:\\clean/path"
4084        );
4085    }
4086
4087    #[test]
4088    fn one_char_scheme_credential_content_masked() {
4089        // Single backslash after the one-character scheme `x:` with
4090        // credential-shaped window content (`:` before the last `@`).
4091        let redacted = redact_url_for_diagnostics("x:\\user:pass@evil");
4092        assert!(
4093            !redacted.contains("user:pass"),
4094            "one-char-scheme backslash authority leaked: {redacted}"
4095        );
4096        assert!(
4097            !redacted.contains("pass"),
4098            "one-char-scheme backslash authority leaked a credential byte: {redacted}"
4099        );
4100    }
4101
4102    #[test]
4103    fn drive_and_unc_inputs_stay_visible() {
4104        // Drive path: single backslash after a one-character scheme, no
4105        // `:` in the candidate window — no qualifying backslash window.
4106        // The parse-success arm lowercases the scheme (`C:` → `c:`); the
4107        // diagnostic content must stay visible with no sentinel and no
4108        // mask (spec scenario: query-redaction/cap rules only).
4109        let drive = redact_url_for_diagnostics("C:\\Users\\x@corp\\file");
4110        assert!(
4111            !drive.contains("[redacted]"),
4112            "drive path must not be sentineled: {drive}"
4113        );
4114        assert!(
4115            !drive.contains("***"),
4116            "drive path must not be masked: {drive}"
4117        );
4118        assert!(
4119            drive.contains("x@corp"),
4120            "drive path keeps its at-sign content visible: {drive}"
4121        );
4122        // UNC path: no scheme prefix before the backslash run; the
4123        // unparseable arm renders it byte-identically.
4124        let unc = redact_url_for_diagnostics("\\\\server\\x@y");
4125        assert_eq!(unc, "\\\\server\\x@y");
4126        assert!(
4127            !unc.contains("[redacted]"),
4128            "UNC path must not be sentineled: {unc}"
4129        );
4130    }
4131
4132    #[test]
4133    fn redact_url_masks_userinfo_and_query() {
4134        let redacted =
4135            redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
4136        assert!(
4137            !redacted.contains("secretpass"),
4138            "password must be masked: {redacted}"
4139        );
4140        assert!(
4141            !redacted.contains("token=abc123"),
4142            "query must be masked: {redacted}"
4143        );
4144        assert!(
4145            !redacted.contains("user@"),
4146            "username must be masked: {redacted}"
4147        );
4148        assert!(
4149            redacted.contains("internal.example"),
4150            "host stays visible: {redacted}"
4151        );
4152        assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
4153    }
4154
4155    #[test]
4156    fn redact_url_keeps_clean_urls_visible() {
4157        let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
4158        assert_eq!(redacted, "https://api.example.com/v1/items");
4159    }
4160
4161    #[test]
4162    fn redact_url_masks_password_only_userinfo() {
4163        let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
4164        assert!(
4165            !redacted.contains("pwsecret"),
4166            "password-only userinfo leaked: {redacted}"
4167        );
4168        assert_eq!(redacted, "http://***@host.example/");
4169
4170        let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
4171        assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
4172        assert_eq!(redacted, "http://***@host.example/api");
4173
4174        let redacted = redact_url_for_diagnostics("http://host.example/api");
4175        assert_eq!(redacted, "http://host.example/api");
4176    }
4177
4178    #[test]
4179    fn redact_url_truncates_unparseable() {
4180        let long = "x".repeat(1000);
4181        let redacted = redact_url_for_diagnostics(&long);
4182        assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
4183    }
4184
4185    /// e_gpt stage-4: the 256-byte cap must not split an appended sentinel.
4186    /// The base is truncated at `256 - sentinel_len` BEFORE the sentinel is
4187    /// appended, so the sentinel always renders intact and the total stays
4188    /// ≤ 256. Both arms (parsed and unparseable) are exercised.
4189    #[test]
4190    fn redact_url_keeps_sentinels_intact_under_256_cap() {
4191        // Parsed arm: base (scheme+host+path) is 250 bytes, so byte 256
4192        // lands inside the appended `?[redacted]` (starts at 250) pre-fix.
4193        let parsed = format!("https://example.com/{}?x=1", "a".repeat(230));
4194        assert!(
4195            url::Url::parse(&parsed).is_ok(),
4196            "fixture must parse: {parsed}"
4197        );
4198        let redacted = redact_url_for_diagnostics(&parsed);
4199        assert!(redacted.len() <= 256, "len={}", redacted.len());
4200        assert!(
4201            redacted.ends_with("?[redacted]"),
4202            "parsed-arm sentinel must render intact: {redacted}"
4203        );
4204
4205        // Unparseable arm: base is 249 bytes, so byte 256 lands inside the
4206        // appended `?[redacted]` (starts at 249) pre-fix.
4207        let unparseable = format!("http://{} ?x=1", "a".repeat(240));
4208        assert!(
4209            url::Url::parse(&unparseable).is_err(),
4210            "fixture must not parse: {unparseable}"
4211        );
4212        let redacted = redact_url_for_diagnostics(&unparseable);
4213        assert!(redacted.len() <= 256, "len={}", redacted.len());
4214        assert!(
4215            redacted.ends_with("?[redacted]"),
4216            "unparseable-arm sentinel must render intact: {redacted}"
4217        );
4218    }
4219
4220    #[test]
4221    fn redact_url_suppresses_unparseable_authority_credentials() {
4222        let fixtures = [
4223            "http://u:secretpw@/x",
4224            "http://u:secretpw@host:99999/x",
4225            "http://u:secretpw@host:99999",
4226            "//u:secretpw@h/x",
4227        ];
4228        for fixture in fixtures {
4229            assert!(
4230                url::Url::parse(fixture).is_err(),
4231                "fixture must be unparseable: {fixture}"
4232            );
4233            let redacted = redact_url_for_diagnostics(fixture);
4234            assert_eq!(
4235                redacted, "[redacted]",
4236                "credential-bearing authority must be suppressed: {fixture}"
4237            );
4238        }
4239    }
4240
4241    #[test]
4242    fn redact_url_bd_repro_never_leaks_credentials() {
4243        let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
4244        assert!(
4245            !redacted.contains("user:pa%ss"),
4246            "bd rc-2i5c5 repro leaked userinfo: {redacted}"
4247        );
4248        assert!(
4249            !redacted.contains("pa%ss"),
4250            "bd rc-2i5c5 repro leaked password: {redacted}"
4251        );
4252    }
4253
4254    #[test]
4255    fn redact_url_unparseable_query_redacted_short_and_long() {
4256        let short = "http://host:99999/path?token=shortsecret";
4257        assert!(
4258            url::Url::parse(short).is_err(),
4259            "fixture must be unparseable: {short}"
4260        );
4261        let redacted = redact_url_for_diagnostics(short);
4262        assert_eq!(
4263            redacted, "http://host:99999/path?[redacted]",
4264            "short unparseable query must end with the suffix: {redacted}"
4265        );
4266
4267        let mut long = String::from("http://host:99999/");
4268        long.push_str(&"a".repeat(300));
4269        long.push_str("?token=longsecret");
4270        assert!(
4271            url::Url::parse(&long).is_err(),
4272            "fixture must be unparseable: {long}"
4273        );
4274        let redacted = redact_url_for_diagnostics(&long);
4275        assert!(
4276            !redacted.contains("longsecret"),
4277            "long unparseable query leaked a query byte: {redacted}"
4278        );
4279        assert!(
4280            redacted.len() <= 256,
4281            "long unparseable query must be capped: {} bytes",
4282            redacted.len()
4283        );
4284    }
4285
4286    #[test]
4287    fn redact_url_unparseable_sentinels_compose_both() {
4288        // Compose-both rule: one sentinel per distinct introducer found in
4289        // the raw string, in first-occurrence order.
4290        let raw = "ht tp://h.example/p?a=1#tok=x";
4291        assert!(
4292            url::Url::parse(raw).is_err(),
4293            "fixture must be unparseable: {raw}"
4294        );
4295        assert_eq!(
4296            redact_url_for_diagnostics(raw),
4297            "ht tp://h.example/p?[redacted]#[redacted]",
4298            "query and fragment sentinels must compose: {raw}"
4299        );
4300    }
4301
4302    #[test]
4303    fn redact_url_unparseable_sentinels_compose_fragment_first() {
4304        let raw = "ht tp://h.example/p#tok=x?a=1";
4305        assert!(
4306            url::Url::parse(raw).is_err(),
4307            "fixture must be unparseable: {raw}"
4308        );
4309        assert_eq!(
4310            redact_url_for_diagnostics(raw),
4311            "ht tp://h.example/p#[redacted]?[redacted]",
4312            "sentinels must follow the introducers' first-occurrence order: {raw}"
4313        );
4314    }
4315
4316    #[test]
4317    fn redact_url_unparseable_utf8_straddle_no_panic() {
4318        let fixture = format!("a{}", "é".repeat(200));
4319        let redacted = redact_url_for_diagnostics(&fixture);
4320        assert!(
4321            redacted.len() <= 256,
4322            "straddle fixture must be capped: {} bytes",
4323            redacted.len()
4324        );
4325        assert!(
4326            redacted.len() >= 253,
4327            "straddle fixture must not over-truncate: {} bytes",
4328            redacted.len()
4329        );
4330        assert!(
4331            fixture.is_char_boundary(redacted.len()),
4332            "cut must land on a UTF-8 char boundary: {} bytes",
4333            redacted.len()
4334        );
4335    }
4336
4337    #[test]
4338    fn redact_url_at_sign_outside_authority_window_visible() {
4339        let at_sign_in_path = "http://host:99999/x@y";
4340        assert!(
4341            url::Url::parse(at_sign_in_path).is_err(),
4342            "fixture must be unparseable: {at_sign_in_path}"
4343        );
4344        assert_eq!(
4345            redact_url_for_diagnostics(at_sign_in_path),
4346            at_sign_in_path,
4347            "at-sign in path must not be suppressed"
4348        );
4349        // mailto parses as a cannot-be-a-base URL (no is_err precondition).
4350        assert_eq!(
4351            redact_url_for_diagnostics("mailto:user@example.com"),
4352            "mailto:user@example.com",
4353            "at-sign in mailto must round-trip byte-identically"
4354        );
4355    }
4356
4357    #[test]
4358    fn parse_success_fragment_composes() {
4359        // Parsed arm: the fragment stays on the rendered URL and the
4360        // canonical redactor drops it and appends the sentinel.
4361        assert_eq!(
4362            redact_url_for_diagnostics("https://h/p#access_token=x"),
4363            "https://h/p#[redacted]"
4364        );
4365        // A `?` inside the fragment composes both sentinels, in
4366        // first-occurrence order (# before ?).
4367        assert_eq!(
4368            redact_url_for_diagnostics("https://h/cb#f?state=x"),
4369            "https://h/cb#[redacted]?[redacted]"
4370        );
4371    }
4372
4373    #[test]
4374    fn err_arm_delegation_pin() {
4375        // Unparseable (port 99999) with userinfo in the authority window:
4376        // the Err arm delegates wholesale to the fail-closed canonical
4377        // redactor — nothing of the URL is rendered.
4378        assert_eq!(
4379            redact_url_for_diagnostics("http://u:secretpw@host:99999/x"),
4380            "[redacted]"
4381        );
4382        // Cross-surface fixture: same unparseable port without userinfo —
4383        // drop at `?`, append the query sentinel.
4384        assert_eq!(
4385            redact_url_for_diagnostics("http://h:99999/p?token=secret"),
4386            "http://h:99999/p?[redacted]"
4387        );
4388    }
4389
4390    #[test]
4391    fn truncate_error_body_caps_attacker_body() {
4392        let big = vec![b'A'; 10 * 1024 * 1024];
4393        let truncated = truncate_error_body(&big);
4394        assert!(
4395            truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
4396            "body must be capped near {} bytes, got {}",
4397            MAX_ERROR_RESPONSE_BODY_BYTES,
4398            truncated.len()
4399        );
4400        assert!(truncated.ends_with("...[truncated]"));
4401    }
4402
4403    #[test]
4404    fn truncate_error_body_keeps_small_body() {
4405        assert_eq!(truncate_error_body(b"boom"), "boom");
4406    }
4407
4408    #[test]
4409    fn test_http_config_defaults() {
4410        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
4411        assert_eq!(config.base_url, "http://localhost:8080/api");
4412        assert!(config.http_method.is_none());
4413        assert!(config.throw_exception_on_failure);
4414        assert_eq!(config.ok_status_code_range, (200, 299));
4415        assert!(config.response_timeout.is_none());
4416        assert!(matches!(config.auth, HttpAuth::None));
4417        assert!(!config.bridge_endpoint);
4418        assert!(!config.connection_close);
4419    }
4420
4421    #[test]
4422    fn test_http_config_scheme() {
4423        // UriConfig trait method returns "http" as primary scheme
4424        assert_eq!(HttpEndpointConfig::scheme(), "http");
4425    }
4426
4427    #[test]
4428    fn test_http_config_from_components() {
4429        // Test from_components directly (trait method)
4430        let components = camel_component_api::UriComponents {
4431            scheme: "https".to_string(),
4432            path: "//api.example.com/v1".to_string(),
4433            params: std::collections::HashMap::from([(
4434                "httpMethod".to_string(),
4435                "POST".to_string(),
4436            )]),
4437            raw_query: None,
4438        };
4439        let config = HttpEndpointConfig::from_components(components).unwrap();
4440        assert_eq!(config.base_url, "https://api.example.com/v1");
4441        assert_eq!(config.http_method, Some("POST".to_string()));
4442    }
4443
4444    #[test]
4445    fn test_http_config_with_options() {
4446        let config = HttpEndpointConfig::from_uri(
4447            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
4448        ).unwrap();
4449        assert_eq!(config.base_url, "https://api.example.com/v1");
4450        assert_eq!(config.http_method, Some("PUT".to_string()));
4451        assert!(!config.throw_exception_on_failure);
4452        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
4453    }
4454
4455    #[test]
4456    fn test_http_endpoint_config_auth_and_headers_options() {
4457        let config = HttpEndpointConfig::from_uri(
4458            "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
4459        )
4460        .unwrap();
4461
4462        assert!(matches!(
4463            config.auth,
4464            HttpAuth::Basic { username, password } if username == "u" && password == "p"
4465        ));
4466        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
4467        assert!(config.bridge_endpoint);
4468        assert!(config.connection_close);
4469        assert_eq!(
4470            config.skip_request_headers,
4471            vec!["authorization".to_string(), "x-secret".to_string()]
4472        );
4473        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
4474    }
4475
4476    #[test]
4477    fn test_http_endpoint_config_bearer_auth() {
4478        let config = HttpEndpointConfig::from_uri(
4479            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
4480        )
4481        .unwrap();
4482        assert!(matches!(
4483            config.auth,
4484            HttpAuth::Bearer { token } if token == "t"
4485        ));
4486    }
4487
4488    #[test]
4489    fn rejects_cookie_handling_inmemory() {
4490        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
4491        match result {
4492            Err(CamelError::InvalidUri(msg)) => {
4493                assert!(
4494                    msg.contains("cookieHandling is not supported"),
4495                    "expected rejection message, got: {msg}"
4496                );
4497            }
4498            other => panic!("expected InvalidUri error, got: {other:?}"),
4499        }
4500    }
4501
4502    #[test]
4503    fn rejects_cookie_handling_disabled() {
4504        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
4505        match result {
4506            Err(CamelError::InvalidUri(msg)) => {
4507                assert!(
4508                    msg.contains("cookieHandling is not supported"),
4509                    "expected rejection message, got: {msg}"
4510                );
4511            }
4512            other => panic!("expected InvalidUri error, got: {other:?}"),
4513        }
4514    }
4515
4516    #[test]
4517    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
4518        let config = HttpConfig::default()
4519            .with_response_timeout_ms(999)
4520            .with_allow_internal(true)
4521            .with_blocked_hosts(vec!["evil.com".to_string()])
4522            .with_max_body_size(12345);
4523        let endpoint =
4524            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
4525        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
4526        assert!(endpoint.allow_internal);
4527        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
4528        assert_eq!(endpoint.max_body_size, 12345);
4529    }
4530
4531    #[test]
4532    fn test_from_uri_with_defaults_uri_overrides_config() {
4533        let config = HttpConfig::default()
4534            .with_response_timeout_ms(999)
4535            .with_allow_internal(true)
4536            .with_blocked_hosts(vec!["evil.com".to_string()])
4537            .with_max_body_size(12345);
4538        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
4539            "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
4540            &config,
4541        )
4542        .unwrap();
4543        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
4544        assert!(!endpoint.allow_internal);
4545        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
4546        assert_eq!(endpoint.max_body_size, 99);
4547    }
4548
4549    #[test]
4550    fn test_http_config_ok_status_range() {
4551        let config =
4552            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
4553        assert_eq!(config.ok_status_code_range, (200, 204));
4554    }
4555
4556    #[test]
4557    fn test_http_config_wrong_scheme() {
4558        let result = HttpEndpointConfig::from_uri("file:/tmp");
4559        assert!(result.is_err());
4560    }
4561
4562    #[test]
4563    fn test_http_component_scheme() {
4564        let component = HttpComponent::new();
4565        assert_eq!(component.scheme(), "http");
4566    }
4567
4568    // -----------------------------------------------------------------------
4569    // tls.strict — fail-closed knob (audit 2026-08-31 R3 / rc-ayrwk).
4570    // Default stays permissive (F2-7 warns); strict fails endpoint creation
4571    // on any CA/mTLS load failure.
4572    // -----------------------------------------------------------------------
4573
4574    #[test]
4575    fn tls_strict_defaults_false_on_deserialize() {
4576        let tls: TlsConfig = serde_json::from_value(serde_json::json!({
4577            "enabled": true
4578        }))
4579        .unwrap();
4580        assert!(!tls.strict, "absent strict must default to false");
4581    }
4582
4583    fn strict_config(ca_path: Option<&str>, strict: bool) -> HttpConfig {
4584        HttpConfig {
4585            tls: Some(TlsConfig {
4586                enabled: true,
4587                strict,
4588                ca_cert_path: ca_path.map(|p| p.to_string()),
4589                ..TlsConfig::default()
4590            }),
4591            ..HttpConfig::default()
4592        }
4593    }
4594
4595    #[test]
4596    fn strict_tls_missing_ca_fails_endpoint_creation() {
4597        let component =
4598            HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), true));
4599        let err = component
4600            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4601            .err()
4602            .expect("strict + missing CA must fail endpoint creation");
4603        assert!(
4604            err.to_string().contains("tls.strict"),
4605            "must name the strict knob: {err}"
4606        );
4607        assert!(
4608            err.to_string().contains("unreadable"),
4609            "must name the failure class: {err}"
4610        );
4611    }
4612
4613    #[test]
4614    fn strict_tls_unparseable_ca_fails_endpoint_creation() {
4615        let path = camel_component_api::test_support::tls::write_pem_tmp(
4616            "strict-bad-ca.pem",
4617            "not a certificate",
4618        );
4619        let component =
4620            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4621        let err = component
4622            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4623            .err()
4624            .expect("strict + unparseable CA must fail endpoint creation");
4625        assert!(
4626            err.to_string()
4627                .contains("no parseable PEM CERTIFICATE section"),
4628            "must name the failure class: {err}"
4629        );
4630    }
4631
4632    #[test]
4633    fn strict_tls_der_file_rejected_not_certified() {
4634        // e_glm stage-4 finding 1: a DER-looking file (first byte 0x30 =
4635        // ASCII '0') must NOT pass strict — the rustls backend never
4636        // enforces lone-DER bundles, so certifying one would certify an
4637        // unenforced config.
4638        let path = camel_component_api::test_support::tls::write_pem_tmp(
4639            "strict-der-ca.pem",
4640            "00garbage-bytes",
4641        );
4642        let component =
4643            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4644        let err = component
4645            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4646            .err()
4647            .expect("strict + DER file must fail endpoint creation");
4648        assert!(
4649            err.to_string().contains("convert to PEM"),
4650            "must tell the operator to convert: {err}"
4651        );
4652    }
4653
4654    #[test]
4655    fn strict_tls_half_mtls_pair_rejected() {
4656        // e_glm stage-4 finding 2: cert XOR key must fail under strict,
4657        // not silently degrade to non-mTLS.
4658        let cfg = strict_mtls_config(Some("/any/cert.pem"), None);
4659        let component = HttpComponent::with_config(cfg);
4660        let err = component
4661            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4662            .err()
4663            .expect("strict + half mTLS pair must fail endpoint creation");
4664        assert!(
4665            err.to_string().contains("BOTH"),
4666            "must name the pair requirement: {err}"
4667        );
4668    }
4669
4670    #[test]
4671    fn strict_tls_valid_material_allows_endpoint_creation() {
4672        let (ca, _cert, _key) = camel_component_api::test_support::tls::gen_server_cert();
4673        let path = camel_component_api::test_support::tls::write_pem_tmp("strict-ok-ca.pem", &ca);
4674        let component =
4675            HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4676        assert!(
4677            component
4678                .create_endpoint("http://localhost/api", &NoOpComponentContext)
4679                .is_ok(),
4680            "valid CA under strict must create the endpoint"
4681        );
4682    }
4683
4684    #[test]
4685    fn permissive_missing_ca_keeps_back_compat() {
4686        // strict absent (false): the F2-7 warn-and-fallback behavior stays;
4687        // endpoint creation succeeds.
4688        let component =
4689            HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), false));
4690        assert!(
4691            component
4692                .create_endpoint("http://localhost/api", &NoOpComponentContext)
4693                .is_ok(),
4694            "permissive mode must keep the back-compat fallback"
4695        );
4696    }
4697
4698    fn strict_mtls_config(cert_path: Option<&str>, key_path: Option<&str>) -> HttpConfig {
4699        HttpConfig {
4700            tls: Some(TlsConfig {
4701                enabled: true,
4702                strict: true,
4703                client_cert_path: cert_path.map(|p| p.to_string()),
4704                client_key_path: key_path.map(|p| p.to_string()),
4705                ..TlsConfig::default()
4706            }),
4707            ..HttpConfig::default()
4708        }
4709    }
4710
4711    #[test]
4712    fn strict_tls_missing_mtls_cert_fails_endpoint_creation() {
4713        // Key present, cert file missing: a half-readable mTLS pair must
4714        // fail creation under strict, not silently drop the identity.
4715        let (_ca, _cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4716        let key_path =
4717            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key.pem", &key);
4718        let component = HttpComponent::with_config(strict_mtls_config(
4719            Some("/nonexistent/cert.pem"),
4720            Some(key_path.to_str().unwrap()),
4721        ));
4722        let err = component
4723            .create_endpoint("http://localhost/api", &NoOpComponentContext)
4724            .err()
4725            .expect("strict + unreadable mTLS pair must fail endpoint creation");
4726        assert!(
4727            err.to_string().contains("tls.strict"),
4728            "must name the strict knob: {err}"
4729        );
4730        assert!(
4731            err.to_string().contains("unreadable"),
4732            "must name the failure class: {err}"
4733        );
4734    }
4735
4736    #[test]
4737    fn strict_tls_valid_mtls_pair_allows_endpoint_creation() {
4738        let (_ca, cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4739        let cert_path =
4740            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-cert.pem", &cert);
4741        let key_path =
4742            camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key2.pem", &key);
4743        let component = HttpComponent::with_config(strict_mtls_config(
4744            Some(cert_path.to_str().unwrap()),
4745            Some(key_path.to_str().unwrap()),
4746        ));
4747        assert!(
4748            component
4749                .create_endpoint("http://localhost/api", &NoOpComponentContext)
4750                .is_ok(),
4751            "valid mTLS pair under strict must create the endpoint"
4752        );
4753    }
4754
4755    #[test]
4756    fn test_https_component_scheme() {
4757        let component = HttpsComponent::new();
4758        assert_eq!(component.scheme(), "https");
4759    }
4760
4761    #[test]
4762    fn test_http_endpoint_creates_consumer() {
4763        let component = HttpComponent::new();
4764        let ctx = NoOpComponentContext;
4765        let endpoint = component
4766            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
4767            .unwrap();
4768        assert!(endpoint.create_consumer(rt()).is_ok());
4769    }
4770
4771    #[test]
4772    fn test_https_endpoint_creates_consumer_errors_without_tls() {
4773        let component = HttpsComponent::new();
4774        let ctx = NoOpComponentContext;
4775        let endpoint = component
4776            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
4777            .unwrap();
4778        // https:// without tlsCert/tlsKey must fail (scheme enforcement)
4779        assert!(endpoint.create_consumer(rt()).is_err());
4780    }
4781
4782    #[test]
4783    fn test_http_endpoint_creates_producer() {
4784        let ctx = test_producer_ctx();
4785        let component = HttpComponent::new();
4786        let endpoint_ctx = NoOpComponentContext;
4787        let endpoint = component
4788            .create_endpoint("http://localhost/api", &endpoint_ctx)
4789            .unwrap();
4790        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
4791    }
4792
4793    // -----------------------------------------------------------------------
4794    // Producer tests
4795    // -----------------------------------------------------------------------
4796
4797    #[tokio::test]
4798    async fn test_producer_with_token_provider() {
4799        use camel_auth::oauth2::TokenProvider;
4800        use tower::ServiceExt;
4801
4802        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
4803            Arc::new(std::sync::Mutex::new(None));
4804        let captured_clone = Arc::clone(&captured_auth);
4805
4806        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4807        let port = listener.local_addr().unwrap().port();
4808
4809        let _handle = tokio::spawn(async move {
4810            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4811            if let Ok((mut stream, _)) = listener.accept().await {
4812                let mut buf = vec![0u8; 8192];
4813                let n = stream.read(&mut buf).await.unwrap_or(0);
4814                let request = String::from_utf8_lossy(&buf[..n]).to_string();
4815                let auth = request
4816                    .lines()
4817                    .find(|l| l.to_lowercase().starts_with("authorization:"))
4818                    .map(|l| {
4819                        l.split(':')
4820                            .nth(1)
4821                            .map(|s| s.trim().to_string())
4822                            .unwrap_or_default()
4823                    });
4824                *captured_clone.lock().unwrap() = auth;
4825                let body = r#"{"echo":"ok"}"#;
4826                let resp = format!(
4827                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4828                    body.len(),
4829                    body
4830                );
4831                let _ = stream.write_all(resp.as_bytes()).await;
4832            }
4833        });
4834
4835        #[derive(Debug)]
4836        struct StaticProvider;
4837        #[async_trait::async_trait]
4838        impl TokenProvider for StaticProvider {
4839            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
4840                Ok("injected-token".into())
4841            }
4842        }
4843
4844        let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
4845        let ctx = test_producer_ctx();
4846        let component = HttpComponent::new();
4847        let endpoint_ctx = NoOpComponentContext;
4848        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
4849        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4850
4851        let exchange = Exchange::new(Message::new("hello"));
4852
4853        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
4854        let mut layered = layer.layer(producer);
4855        let result = layered.ready().await.unwrap().call(exchange).await;
4856        assert!(result.is_ok(), "producer call failed: {:?}", result);
4857
4858        tokio::time::sleep(Duration::from_millis(100)).await;
4859        let auth = captured_auth.lock().unwrap().take();
4860        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
4861    }
4862
4863    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
4864        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4865        let addr = listener.local_addr().unwrap();
4866        let url = format!("http://127.0.0.1:{}", addr.port());
4867
4868        let handle = tokio::spawn(async move {
4869            loop {
4870                if let Ok((mut stream, _)) = listener.accept().await {
4871                    tokio::spawn(async move {
4872                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4873                        let mut buf = vec![0u8; 4096];
4874                        let n = stream.read(&mut buf).await.unwrap_or(0);
4875                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
4876
4877                        let method = request.split_whitespace().next().unwrap_or("GET");
4878
4879                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
4880                        let response = format!(
4881                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
4882                            body.len(),
4883                            body
4884                        );
4885                        let _ = stream.write_all(response.as_bytes()).await;
4886                    });
4887                }
4888            }
4889        });
4890
4891        (url, handle)
4892    }
4893
4894    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
4895        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4896        let addr = listener.local_addr().unwrap();
4897        let url = format!("http://127.0.0.1:{}", addr.port());
4898
4899        let handle = tokio::spawn(async move {
4900            loop {
4901                if let Ok((mut stream, _)) = listener.accept().await {
4902                    let status = status;
4903                    tokio::spawn(async move {
4904                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4905                        let mut buf = vec![0u8; 4096];
4906                        let _ = stream.read(&mut buf).await;
4907
4908                        let status_text = match status {
4909                            404 => "Not Found",
4910                            500 => "Internal Server Error",
4911                            _ => "Error",
4912                        };
4913                        let body = "error body";
4914                        let response = format!(
4915                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
4916                            status,
4917                            status_text,
4918                            body.len(),
4919                            body
4920                        );
4921                        let _ = stream.write_all(response.as_bytes()).await;
4922                    });
4923                }
4924            }
4925        });
4926
4927        (url, handle)
4928    }
4929
4930    async fn start_request_capturing_server() -> (
4931        String,
4932        Arc<std::sync::Mutex<Option<String>>>,
4933        tokio::task::JoinHandle<()>,
4934    ) {
4935        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4936        let port = listener.local_addr().unwrap().port();
4937        let url = format!("http://127.0.0.1:{port}");
4938        let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
4939        let captured_clone = Arc::clone(&captured);
4940        let handle = tokio::spawn(async move {
4941            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4942            if let Ok((mut stream, _)) = listener.accept().await {
4943                let mut buf = vec![0u8; 16384];
4944                let n = stream.read(&mut buf).await.unwrap_or(0);
4945                let request = String::from_utf8_lossy(&buf[..n]).to_string();
4946                if request.contains("\r\n\r\n") {
4947                    *captured_clone.lock().unwrap() = Some(request);
4948                }
4949                let body = r#"{"echo":"ok"}"#;
4950                let resp = format!(
4951                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4952                    body.len(),
4953                    body
4954                );
4955                let _ = stream.write_all(resp.as_bytes()).await;
4956            }
4957        });
4958        (url, captured, handle)
4959    }
4960
4961    #[tokio::test]
4962    async fn test_http_producer_get_request() {
4963        use tower::ServiceExt;
4964
4965        let (url, _handle) = start_test_server().await;
4966        let ctx = test_producer_ctx();
4967
4968        let component = HttpComponent::new();
4969        let endpoint_ctx = NoOpComponentContext;
4970        let endpoint = component
4971            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4972            .unwrap();
4973        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4974
4975        let exchange = Exchange::new(Message::default());
4976        let result = producer.oneshot(exchange).await.unwrap();
4977
4978        let status = result
4979            .input
4980            .header("CamelHttpResponseCode")
4981            .and_then(|v| v.as_u64())
4982            .unwrap();
4983        assert_eq!(status, 200);
4984
4985        assert!(!result.input.body.is_empty());
4986    }
4987
4988    #[tokio::test]
4989    async fn producer_excludes_host_and_framing() {
4990        use tower::ServiceExt;
4991
4992        let (url, captured, _handle) = start_request_capturing_server().await;
4993        let ctx = test_producer_ctx();
4994        let component = HttpComponent::new();
4995        let endpoint_ctx = NoOpComponentContext;
4996        let endpoint = component
4997            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4998            .unwrap();
4999        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5000
5001        let mut exchange = Exchange::new(Message::default());
5002        exchange.input.set_header("Host", "localhost");
5003        exchange.input.set_header("Content-Length", "42");
5004        exchange.input.set_header("Connection", "keep-alive");
5005        exchange.input.set_header("Upgrade", "h2c");
5006
5007        let result = producer.oneshot(exchange).await;
5008        assert!(result.is_ok(), "producer call failed: {:?}", result);
5009
5010        tokio::time::sleep(Duration::from_millis(100)).await;
5011        let request = captured
5012            .lock()
5013            .unwrap()
5014            .take()
5015            .expect("no outbound request captured");
5016        let lower = request.to_ascii_lowercase();
5017        assert!(
5018            !lower.contains("\r\nhost: localhost"),
5019            "forwarded Host: localhost must be stripped\n{request}"
5020        );
5021        assert!(
5022            !lower.contains("content-length: 42"),
5023            "exchange Content-Length must not be copied\n{request}"
5024        );
5025        assert!(
5026            !lower.lines().any(|l| l.starts_with("connection:")),
5027            "Connection header must not be forwarded\n{request}"
5028        );
5029        assert!(
5030            !lower.lines().any(|l| l.starts_with("upgrade:")),
5031            "Upgrade header must not be forwarded\n{request}"
5032        );
5033        let host_header = lower
5034            .lines()
5035            .find(|l| l.starts_with("host:"))
5036            .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
5037            .expect("outbound Host header must be set by reqwest");
5038        assert!(
5039            host_header.starts_with("127.0.0.1:"),
5040            "outbound Host '{host_header}' must match the capture-server address"
5041        );
5042    }
5043
5044    #[tokio::test]
5045    async fn producer_forwards_request_only_headers() {
5046        use tower::ServiceExt;
5047
5048        let (url, captured, _handle) = start_request_capturing_server().await;
5049        let ctx = test_producer_ctx();
5050        let component = HttpComponent::new();
5051        let endpoint_ctx = NoOpComponentContext;
5052        let endpoint = component
5053            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5054            .unwrap();
5055        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5056
5057        let mut exchange = Exchange::new(Message::default());
5058        exchange.input.set_header("Accept", "application/json");
5059        exchange.input.set_header("User-Agent", "myclient/1.0");
5060
5061        let result = producer.oneshot(exchange).await;
5062        assert!(result.is_ok(), "producer call failed: {:?}", result);
5063
5064        tokio::time::sleep(Duration::from_millis(100)).await;
5065        let request = captured
5066            .lock()
5067            .unwrap()
5068            .take()
5069            .expect("no outbound request captured");
5070        let lower = request.to_ascii_lowercase();
5071        assert!(
5072            lower.contains("accept: application/json"),
5073            "request-only Accept header must be forwarded\n{request}"
5074        );
5075        assert!(
5076            lower.contains("user-agent: myclient/1.0"),
5077            "request-only User-Agent header must be forwarded\n{request}"
5078        );
5079    }
5080
5081    // -----------------------------------------------------------------------
5082    // Configured-header construction failures are surfaced, never silent
5083    // (rc-jbs1v)
5084    // -----------------------------------------------------------------------
5085
5086    /// Build an endpoint whose URI parses normally but whose `user_agent`
5087    /// and `auth` are then overridden programmatically, so CRLF-bearing
5088    /// test values never pass through URI parsing.
5089    fn endpoint_with_config_overrides(
5090        base_url: &str,
5091        user_agent: Option<String>,
5092        auth: HttpAuth,
5093    ) -> HttpEndpoint {
5094        let uri = format!("{base_url}/api/test?allowInternal=true");
5095        let mut config =
5096            HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
5097        config.user_agent = user_agent;
5098        config.auth = auth;
5099        HttpEndpoint {
5100            uri: uri.clone(),
5101            config,
5102            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
5103            client: reqwest::Client::new(),
5104            pinned_cache: Arc::new(PinnedClientCache::new(
5105                PINNED_CLIENT_TTL,
5106                PINNED_CLIENT_MAX_ENTRIES,
5107            )),
5108            http_config: HttpConfig::default(),
5109        }
5110    }
5111
5112    /// A configured user-agent / bearer token that fails `HeaderValue`
5113    /// construction must be dropped with a DEBUG record (name + reason
5114    /// only, never the value — ADR-0051) and reach the wire absent, while
5115    /// a valid config passes through unchanged.
5116    #[tracing_test::traced_test]
5117    #[tokio::test]
5118    async fn producer_invalid_configured_headers_surfaced() {
5119        use tower::ServiceExt;
5120
5121        let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
5122        let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
5123        let ctx = test_producer_ctx();
5124
5125        let bad_producer = endpoint_with_config_overrides(
5126            &bad_url,
5127            Some("bad\r\nua".to_string()),
5128            HttpAuth::Bearer {
5129                token: "tok\r\nen".to_string(),
5130            },
5131        )
5132        .create_producer(rt(), &ctx)
5133        .unwrap();
5134        let ok_producer = endpoint_with_config_overrides(
5135            &ok_url,
5136            Some("httpsweep-ok/1".to_string()),
5137            HttpAuth::Bearer {
5138                token: "valid-token".to_string(),
5139            },
5140        )
5141        .create_producer(rt(), &ctx)
5142        .unwrap();
5143
5144        let bad_exchange = Exchange::new(Message::default());
5145        let ok_exchange = Exchange::new(Message::default());
5146        let bad_cid = bad_exchange.correlation_id().to_string();
5147        let ok_cid = ok_exchange.correlation_id().to_string();
5148
5149        let bad_result = bad_producer.oneshot(bad_exchange).await;
5150        assert!(
5151            bad_result.is_ok(),
5152            "invalid-config producer call failed: {bad_result:?}"
5153        );
5154        let ok_result = ok_producer.oneshot(ok_exchange).await;
5155        assert!(
5156            ok_result.is_ok(),
5157            "valid-config producer call failed: {ok_result:?}"
5158        );
5159
5160        tokio::time::sleep(Duration::from_millis(100)).await;
5161        let bad_request = bad_captured
5162            .lock()
5163            .unwrap()
5164            .take()
5165            .expect("no outbound request captured");
5166        let ok_request = ok_captured
5167            .lock()
5168            .unwrap()
5169            .take()
5170            .expect("no outbound request captured");
5171
5172        // Invalid config: neither header reaches the wire. Value-absence,
5173        // not "any UA" — reqwest may inject a default user-agent.
5174        let bad_lower = bad_request.to_ascii_lowercase();
5175        assert!(
5176            !bad_lower.lines().any(|l| l.starts_with("authorization:")),
5177            "invalid Bearer token must not reach the wire\n{bad_request}"
5178        );
5179        assert!(
5180            !bad_request.contains("bad\r\nua"),
5181            "invalid configured user-agent must not reach the wire\n{bad_request}"
5182        );
5183
5184        logs_assert(|lines: &[&str]| {
5185            let drops: Vec<&&str> = lines
5186                .iter()
5187                .filter(|l| {
5188                    l.contains("outbound header dropped")
5189                        && l.contains(&format!("correlation_id={bad_cid}"))
5190                })
5191                .collect();
5192            if drops.len() != 2 {
5193                return Err(format!(
5194                    "expected exactly 2 drop records for {bad_cid}, found {}",
5195                    drops.len()
5196                ));
5197            }
5198            let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
5199            let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
5200            let reason_ok = drops
5201                .iter()
5202                .all(|l| l.contains("outbound header dropped: invalid header value"));
5203            match (has_ua, has_auth, reason_ok) {
5204                (true, true, true) => Ok(()),
5205                _ => Err(format!(
5206                    "drop records mismatched: user-agent={has_ua} \
5207                     authorization={has_auth} reason-ok={reason_ok}"
5208                )),
5209            }
5210        });
5211        logs_assert(|lines: &[&str]| {
5212            if lines
5213                .iter()
5214                .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
5215            {
5216                Err("sentinel CRLF values leaked into logs".to_string())
5217            } else {
5218                Ok(())
5219            }
5220        });
5221
5222        // Valid config: both headers reach the wire exactly as configured,
5223        // with zero drop records.
5224        let ok_lower = ok_request.to_ascii_lowercase();
5225        assert!(
5226            ok_lower.contains("user-agent: httpsweep-ok/1"),
5227            "valid configured user-agent must reach the wire\n{ok_request}"
5228        );
5229        assert!(
5230            ok_lower.contains("authorization: bearer valid-token"),
5231            "valid Bearer token must reach the wire\n{ok_request}"
5232        );
5233        logs_assert(|lines: &[&str]| {
5234            let hits = lines
5235                .iter()
5236                .filter(|l| {
5237                    l.contains("outbound header dropped")
5238                        && l.contains(&format!("correlation_id={ok_cid}"))
5239                })
5240                .count();
5241            match hits {
5242                0 => Ok(()),
5243                n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
5244            }
5245        });
5246    }
5247
5248    #[tokio::test]
5249    async fn producer_honours_skip_request_headers() {
5250        use tower::ServiceExt;
5251
5252        let (url, captured, _handle) = start_request_capturing_server().await;
5253        let ctx = test_producer_ctx();
5254        let component = HttpComponent::new();
5255        let endpoint_ctx = NoOpComponentContext;
5256        let endpoint = component
5257            .create_endpoint(
5258                &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
5259                &endpoint_ctx,
5260            )
5261            .unwrap();
5262        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5263
5264        let mut exchange = Exchange::new(Message::default());
5265        exchange.input.set_header("Authorization", "Bearer x");
5266
5267        let result = producer.oneshot(exchange).await;
5268        assert!(result.is_ok(), "producer call failed: {:?}", result);
5269
5270        tokio::time::sleep(Duration::from_millis(100)).await;
5271        let request = captured
5272            .lock()
5273            .unwrap()
5274            .take()
5275            .expect("no outbound request captured");
5276        assert!(
5277            !request.to_ascii_lowercase().contains("authorization"),
5278            "Authorization must be stripped by skipRequestHeaders\n{request}"
5279        );
5280    }
5281
5282    #[tokio::test]
5283    async fn producer_stringifies_scalar_header_values_on_wire() {
5284        use tower::ServiceExt;
5285
5286        let (url, captured, _handle) = start_request_capturing_server().await;
5287        let ctx = test_producer_ctx();
5288        let component = HttpComponent::new();
5289        let endpoint_ctx = NoOpComponentContext;
5290        let endpoint = component
5291            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5292            .unwrap();
5293        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5294
5295        let mut exchange = Exchange::new(Message::default());
5296        exchange.input.set_header("X-Retries", serde_json::json!(3));
5297        exchange
5298            .input
5299            .set_header("X-Enabled", serde_json::json!(true));
5300        exchange
5301            .input
5302            .set_header("X-Obj", serde_json::json!({"a": 1}));
5303
5304        let result = producer.oneshot(exchange).await;
5305        assert!(result.is_ok(), "producer call failed: {:?}", result);
5306
5307        tokio::time::sleep(Duration::from_millis(100)).await;
5308        let request = captured
5309            .lock()
5310            .unwrap()
5311            .take()
5312            .expect("no outbound request captured");
5313        let lower = request.to_ascii_lowercase();
5314        assert!(
5315            lower.contains("x-retries: 3"),
5316            "numeric header must reach the wire stringified\n{request}"
5317        );
5318        assert!(
5319            lower.contains("x-enabled: true"),
5320            "bool header must reach the wire stringified\n{request}"
5321        );
5322        assert!(
5323            !lower.contains("x-obj:"),
5324            "object header has no single-value form and must not reach the wire\n{request}"
5325        );
5326    }
5327
5328    #[tokio::test]
5329    async fn test_http_producer_post_with_body() {
5330        use tower::ServiceExt;
5331
5332        let (url, _handle) = start_test_server().await;
5333        let ctx = test_producer_ctx();
5334
5335        let component = HttpComponent::new();
5336        let endpoint_ctx = NoOpComponentContext;
5337        let endpoint = component
5338            .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
5339            .unwrap();
5340        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5341
5342        let exchange = Exchange::new(Message::new("request body"));
5343        let result = producer.oneshot(exchange).await.unwrap();
5344
5345        let status = result
5346            .input
5347            .header("CamelHttpResponseCode")
5348            .and_then(|v| v.as_u64())
5349            .unwrap();
5350        assert_eq!(status, 200);
5351    }
5352
5353    #[tokio::test]
5354    async fn test_http_producer_method_from_header() {
5355        use tower::ServiceExt;
5356
5357        let (url, _handle) = start_test_server().await;
5358        let ctx = test_producer_ctx();
5359
5360        let component = HttpComponent::new();
5361        let endpoint_ctx = NoOpComponentContext;
5362        let endpoint = component
5363            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5364            .unwrap();
5365        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5366
5367        let mut exchange = Exchange::new(Message::default());
5368        exchange.input.set_header(
5369            "CamelHttpMethod",
5370            serde_json::Value::String("DELETE".to_string()),
5371        );
5372
5373        let result = producer.oneshot(exchange).await.unwrap();
5374        let status = result
5375            .input
5376            .header("CamelHttpResponseCode")
5377            .and_then(|v| v.as_u64())
5378            .unwrap();
5379        assert_eq!(status, 200);
5380    }
5381
5382    #[tokio::test]
5383    async fn test_http_producer_forced_method() {
5384        use tower::ServiceExt;
5385
5386        let (url, _handle) = start_test_server().await;
5387        let ctx = test_producer_ctx();
5388
5389        let component = HttpComponent::new();
5390        let endpoint_ctx = NoOpComponentContext;
5391        let endpoint = component
5392            .create_endpoint(
5393                &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
5394                &endpoint_ctx,
5395            )
5396            .unwrap();
5397        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5398
5399        let exchange = Exchange::new(Message::default());
5400        let result = producer.oneshot(exchange).await.unwrap();
5401
5402        let status = result
5403            .input
5404            .header("CamelHttpResponseCode")
5405            .and_then(|v| v.as_u64())
5406            .unwrap();
5407        assert_eq!(status, 200);
5408    }
5409
5410    #[tokio::test]
5411    async fn test_http_producer_throw_exception_on_failure() {
5412        use tower::ServiceExt;
5413
5414        let (url, _handle) = start_status_server(404).await;
5415        let ctx = test_producer_ctx();
5416
5417        let component = HttpComponent::new();
5418        let endpoint_ctx = NoOpComponentContext;
5419        let endpoint = component
5420            .create_endpoint(
5421                &format!("{url}/not-found?allowInternal=true"),
5422                &endpoint_ctx,
5423            )
5424            .unwrap();
5425        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5426
5427        let exchange = Exchange::new(Message::default());
5428        let result = producer.oneshot(exchange).await;
5429        assert!(result.is_err());
5430
5431        match result.unwrap_err() {
5432            CamelError::HttpOperationFailed { status_code, .. } => {
5433                assert_eq!(status_code, 404);
5434            }
5435            e => panic!("Expected HttpOperationFailed, got: {e}"),
5436        }
5437    }
5438
5439    #[tokio::test]
5440    async fn test_http_producer_no_throw_on_failure() {
5441        use tower::ServiceExt;
5442
5443        let (url, _handle) = start_status_server(500).await;
5444        let ctx = test_producer_ctx();
5445
5446        let component = HttpComponent::new();
5447        let endpoint_ctx = NoOpComponentContext;
5448        let endpoint = component
5449            .create_endpoint(
5450                &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
5451                &endpoint_ctx,
5452            )
5453            .unwrap();
5454        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5455
5456        let exchange = Exchange::new(Message::default());
5457        let result = producer.oneshot(exchange).await.unwrap();
5458
5459        let status = result
5460            .input
5461            .header("CamelHttpResponseCode")
5462            .and_then(|v| v.as_u64())
5463            .unwrap();
5464        assert_eq!(status, 500);
5465    }
5466
5467    #[tokio::test]
5468    async fn test_http_producer_uri_override() {
5469        use tower::ServiceExt;
5470
5471        let (url, _handle) = start_test_server().await;
5472        let ctx = test_producer_ctx();
5473
5474        let component = HttpComponent::new();
5475        let endpoint_ctx = NoOpComponentContext;
5476        let endpoint = component
5477            .create_endpoint(
5478                "http://localhost:1/does-not-exist?allowInternal=true",
5479                &endpoint_ctx,
5480            )
5481            .unwrap();
5482        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5483
5484        let mut exchange = Exchange::new(Message::default());
5485        exchange.input.set_header(
5486            "CamelHttpUri",
5487            serde_json::Value::String(format!("{url}/api")),
5488        );
5489
5490        let result = producer.oneshot(exchange).await.unwrap();
5491        let status = result
5492            .input
5493            .header("CamelHttpResponseCode")
5494            .and_then(|v| v.as_u64())
5495            .unwrap();
5496        assert_eq!(status, 200);
5497    }
5498
5499    #[tokio::test]
5500    async fn test_http_producer_response_headers_mapped() {
5501        use tower::ServiceExt;
5502
5503        let (url, _handle) = start_test_server().await;
5504        let ctx = test_producer_ctx();
5505
5506        let component = HttpComponent::new();
5507        let endpoint_ctx = NoOpComponentContext;
5508        let endpoint = component
5509            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5510            .unwrap();
5511        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5512
5513        let exchange = Exchange::new(Message::default());
5514        let result = producer.oneshot(exchange).await.unwrap();
5515
5516        assert!(
5517            result.input.header("Content-Type").is_some(),
5518            "Response should have Content-Type header"
5519        );
5520        assert!(result.input.header("CamelHttpResponseText").is_some());
5521    }
5522
5523    // -----------------------------------------------------------------------
5524    // Bug fix tests: Client configuration per-endpoint
5525    // -----------------------------------------------------------------------
5526
5527    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
5528        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5529        let addr = listener.local_addr().unwrap();
5530        let url = format!("http://127.0.0.1:{}", addr.port());
5531
5532        let handle = tokio::spawn(async move {
5533            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5534            loop {
5535                if let Ok((mut stream, _)) = listener.accept().await {
5536                    tokio::spawn(async move {
5537                        let mut buf = vec![0u8; 4096];
5538                        let n = stream.read(&mut buf).await.unwrap_or(0);
5539                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
5540
5541                        // Check if this is a request to /final
5542                        if request.contains("GET /final") {
5543                            let body = r#"{"status":"final"}"#;
5544                            let response = format!(
5545                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5546                                body.len(),
5547                                body
5548                            );
5549                            let _ = stream.write_all(response.as_bytes()).await;
5550                        } else {
5551                            // Redirect to /final
5552                            // Connection: close stops the client pooling the
5553                            // connection the server drops right after this
5554                            // response (pooled-race, rc-u3aw class).
5555                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5556                            let _ = stream.write_all(response.as_bytes()).await;
5557                        }
5558                    });
5559                }
5560            }
5561        });
5562
5563        (url, handle)
5564    }
5565
5566    struct CapturedRequest {
5567        method: String,
5568        path: String,
5569        body: Vec<u8>,
5570        content_length: Option<String>,
5571        transfer_encoding: Option<String>,
5572    }
5573
5574    /// Parse a request head plus its Content-Length-driven body from a freshly
5575    /// accepted connection. Returns `None` if the client closes before sending
5576    /// a complete request head. Does NOT read until EOF/shutdown (reqwest pools
5577    /// keep-alive connections and never sends FIN) and does NOT rely on a
5578    /// single fixed-size read (a segmented small body would flake).
5579    async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
5580        use tokio::io::AsyncReadExt;
5581
5582        // Read the request head (up to and including the terminating CRLF CRLF).
5583        let mut buf: Vec<u8> = Vec::new();
5584        let mut chunk = [0u8; 4096];
5585        let head_end: usize;
5586        loop {
5587            let n = stream.read(&mut chunk).await.unwrap_or(0);
5588            if n == 0 {
5589                return None;
5590            }
5591            buf.extend_from_slice(&chunk[..n]);
5592            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
5593                head_end = pos + 4;
5594                break;
5595            }
5596        }
5597
5598        // Parse the request head.
5599        let head = String::from_utf8_lossy(&buf[..head_end]);
5600        let mut lines = head.split("\r\n");
5601        let request_line = lines.next().unwrap_or("");
5602        let mut parts = request_line.split_whitespace();
5603        let method = parts.next().unwrap_or("").to_string();
5604        let path = parts.next().unwrap_or("").to_string();
5605
5606        let mut content_length: Option<String> = None;
5607        let mut transfer_encoding: Option<String> = None;
5608        for line in lines {
5609            if let Some((name, value)) = line.split_once(':') {
5610                let name = name.trim().to_ascii_lowercase();
5611                let value = value.trim().to_string();
5612                if name == "content-length" {
5613                    content_length = Some(value);
5614                } else if name == "transfer-encoding" {
5615                    transfer_encoding = Some(value);
5616                }
5617            }
5618        }
5619
5620        // Content-Length-driven exact read. A missing header means a 0-length body.
5621        let body_len: usize = content_length
5622            .as_deref()
5623            .and_then(|v| v.parse::<usize>().ok())
5624            .unwrap_or(0);
5625
5626        let mut body: Vec<u8> = buf[head_end..].to_vec();
5627        while body.len() < body_len {
5628            let n = stream.read(&mut chunk).await.unwrap_or(0);
5629            if n == 0 {
5630                break;
5631            }
5632            body.extend_from_slice(&chunk[..n]);
5633        }
5634        body.truncate(body_len);
5635
5636        Some(CapturedRequest {
5637            method,
5638            path,
5639            body,
5640            content_length,
5641            transfer_encoding,
5642        })
5643    }
5644
5645    /// A raw-TCP capture server. Each connection parses the request head, then
5646    /// performs a Content-Length-driven exact read of the body (see
5647    /// [`capture_request`]). Each connection is dropped after the response so
5648    /// every hop opens a fresh connection.
5649    async fn start_capture_server() -> (
5650        String,
5651        tokio::task::JoinHandle<()>,
5652        Arc<Mutex<Vec<CapturedRequest>>>,
5653    ) {
5654        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5655        let addr = listener.local_addr().unwrap();
5656        let url = format!("http://127.0.0.1:{}", addr.port());
5657
5658        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5659        let captured_for_return = Arc::clone(&captured);
5660
5661        let handle = tokio::spawn(async move {
5662            use tokio::io::AsyncWriteExt;
5663            loop {
5664                if let Ok((mut stream, _)) = listener.accept().await {
5665                    let captured = Arc::clone(&captured);
5666                    tokio::spawn(async move {
5667                        let Some(req) = capture_request(&mut stream).await else {
5668                            return;
5669                        };
5670                        captured.lock().unwrap().push(req);
5671
5672                        // 200 OK with Content-Length: 0 and no body, then drop
5673                        // the stream so the client opens a fresh connection.
5674                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
5675                        let _ = stream.write_all(response.as_bytes()).await;
5676                    });
5677                }
5678            }
5679        });
5680
5681        (url, handle, captured_for_return)
5682    }
5683
5684    /// A raw-TCP capture server whose `/hop307` and `/hop308` paths answer with
5685    /// `307 Temporary Redirect` / `308 Permanent Redirect` to `/final`, and
5686    /// whose `/final` path answers `200 OK` with an empty body. Every hop
5687    /// records a `CapturedRequest` (Content-Length-driven exact read) and drops
5688    /// the connection after responding so each hop is a fresh connection.
5689    async fn start_redirect_capture_server() -> (
5690        String,
5691        tokio::task::JoinHandle<()>,
5692        Arc<Mutex<Vec<CapturedRequest>>>,
5693    ) {
5694        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5695        let addr = listener.local_addr().unwrap();
5696        let url = format!("http://127.0.0.1:{}", addr.port());
5697
5698        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5699        let captured_for_return = Arc::clone(&captured);
5700
5701        let handle = tokio::spawn(async move {
5702            use tokio::io::AsyncWriteExt;
5703            loop {
5704                if let Ok((mut stream, _)) = listener.accept().await {
5705                    let captured = Arc::clone(&captured);
5706                    tokio::spawn(async move {
5707                        let Some(req) = capture_request(&mut stream).await else {
5708                            return;
5709                        };
5710                        let path = req.path.clone();
5711                        captured.lock().unwrap().push(req);
5712
5713                        let (status_line, location) = match path.as_str() {
5714                            "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
5715                            "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
5716                            "/final" => ("HTTP/1.1 200 OK", None),
5717                            _ => ("HTTP/1.1 404 Not Found", None),
5718                        };
5719
5720                        let response = match location {
5721                            // Connection: close stops the client pooling the
5722                            // connection this handler drops right after the
5723                            // response (pooled-race, rc-u3aw class).
5724                            Some(loc) => format!(
5725                                "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
5726                            ),
5727                            None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
5728                        };
5729                        let _ = stream.write_all(response.as_bytes()).await;
5730                    });
5731                }
5732            }
5733        });
5734
5735        (url, handle, captured_for_return)
5736    }
5737
5738    #[tokio::test]
5739    async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
5740        use tower::ServiceExt;
5741
5742        let (url, _handle, captured) = start_capture_server().await;
5743        let ctx = test_producer_ctx();
5744
5745        let component = HttpComponent::with_config(HttpConfig::default());
5746        let endpoint_ctx = NoOpComponentContext;
5747        let endpoint = component
5748            .create_endpoint(
5749                &format!("{url}?httpMethod=GET&allowInternal=true"),
5750                &endpoint_ctx,
5751            )
5752            .unwrap();
5753        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5754
5755        let mut exchange = Exchange::new(Message::default());
5756        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5757
5758        let result = producer.oneshot(exchange).await.unwrap();
5759
5760        let status = result
5761            .input
5762            .header("CamelHttpResponseCode")
5763            .and_then(|v| v.as_u64())
5764            .unwrap();
5765        assert_eq!(status, 200);
5766
5767        let captured = captured.lock().unwrap();
5768        assert_eq!(captured.len(), 1, "expected exactly one captured request");
5769        let req = &captured[0];
5770        assert_eq!(req.method, "GET");
5771        // `httpMethod`/`allowInternal` are URI options, not request-target
5772        // query params, so the origin-form target is just "/".
5773        assert_eq!(req.path, "/");
5774        assert!(req.body.is_empty(), "GET must not carry a body");
5775        assert!(
5776            req.content_length.is_none(),
5777            "suppressed request must not carry Content-Length"
5778        );
5779        assert!(
5780            req.transfer_encoding.is_none(),
5781            "suppressed request must not carry Transfer-Encoding"
5782        );
5783
5784        // The exchange body is consumed by the producer (std::mem::take).
5785        assert!(
5786            result.input.body.is_empty(),
5787            "exchange body must be consumed"
5788        );
5789    }
5790
5791    #[tokio::test]
5792    async fn test_head_with_body_suppressed_via_header() {
5793        use tower::ServiceExt;
5794
5795        let (url, _handle, captured) = start_capture_server().await;
5796        let ctx = test_producer_ctx();
5797
5798        let component = HttpComponent::with_config(HttpConfig::default());
5799        let endpoint_ctx = NoOpComponentContext;
5800        let endpoint = component
5801            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5802            .unwrap();
5803        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5804
5805        let mut exchange = Exchange::new(Message::default());
5806        exchange.input.set_header(
5807            "CamelHttpMethod",
5808            serde_json::Value::String("HEAD".to_string()),
5809        );
5810        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5811
5812        let result = producer.oneshot(exchange).await.unwrap();
5813        let status = result
5814            .input
5815            .header("CamelHttpResponseCode")
5816            .and_then(|v| v.as_u64())
5817            .unwrap();
5818        assert_eq!(status, 200);
5819
5820        let captured = captured.lock().unwrap();
5821        assert_eq!(captured.len(), 1);
5822        let req = &captured[0];
5823        assert_eq!(req.method, "HEAD");
5824        assert!(req.body.is_empty(), "HEAD must not carry a body");
5825    }
5826
5827    #[tokio::test]
5828    async fn test_delete_options_trace_with_body_suppressed() {
5829        use tower::ServiceExt;
5830
5831        let (url, _handle, captured) = start_capture_server().await;
5832        let ctx = test_producer_ctx();
5833        let component = HttpComponent::with_config(HttpConfig::default());
5834        let endpoint_ctx = NoOpComponentContext;
5835
5836        for method in ["DELETE", "OPTIONS", "TRACE"] {
5837            let endpoint = component
5838                .create_endpoint(
5839                    &format!("{url}?httpMethod={method}&allowInternal=true"),
5840                    &endpoint_ctx,
5841                )
5842                .unwrap();
5843            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5844
5845            let mut exchange = Exchange::new(Message::default());
5846            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5847
5848            let result = producer.oneshot(exchange).await.unwrap();
5849            let status = result
5850                .input
5851                .header("CamelHttpResponseCode")
5852                .and_then(|v| v.as_u64())
5853                .unwrap();
5854            assert_eq!(status, 200, "method {method} should succeed");
5855        }
5856
5857        let captured = captured.lock().unwrap();
5858        assert_eq!(captured.len(), 3, "expected three captured requests");
5859        for method in ["DELETE", "OPTIONS", "TRACE"] {
5860            let req = captured
5861                .iter()
5862                .find(|r| r.method == method)
5863                .unwrap_or_else(|| panic!("missing captured request for {method}"));
5864            assert!(req.body.is_empty(), "{} must not carry a body", req.method);
5865        }
5866    }
5867
5868    #[tokio::test]
5869    async fn test_post_put_patch_with_body_still_sent() {
5870        use tower::ServiceExt;
5871
5872        let (url, _handle, captured) = start_capture_server().await;
5873        let ctx = test_producer_ctx();
5874        let component = HttpComponent::with_config(HttpConfig::default());
5875        let endpoint_ctx = NoOpComponentContext;
5876
5877        for method in ["POST", "PUT", "PATCH"] {
5878            let endpoint = component
5879                .create_endpoint(
5880                    &format!("{url}?httpMethod={method}&allowInternal=true"),
5881                    &endpoint_ctx,
5882                )
5883                .unwrap();
5884            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5885
5886            let payload = format!("body-for-{method}");
5887            let mut exchange = Exchange::new(Message::default());
5888            exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
5889
5890            let result = producer.oneshot(exchange).await.unwrap();
5891            let status = result
5892                .input
5893                .header("CamelHttpResponseCode")
5894                .and_then(|v| v.as_u64())
5895                .unwrap();
5896            assert_eq!(status, 200, "method {method} should succeed");
5897        }
5898
5899        let captured = captured.lock().unwrap();
5900        assert_eq!(captured.len(), 3, "expected three captured requests");
5901        for method in ["POST", "PUT", "PATCH"] {
5902            let req = captured
5903                .iter()
5904                .find(|r| r.method == method)
5905                .unwrap_or_else(|| panic!("missing captured request for {method}"));
5906            let expected = format!("body-for-{method}");
5907            assert!(!req.body.is_empty(), "{method} must still carry its body");
5908            assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
5909        }
5910    }
5911
5912    /// A GET with a stream body must not attach the stream: the entity-enclosing
5913    /// gate drops the stream (mem::take) before the request is built, leaving
5914    /// the exchange body Empty instead of a partially-consumed Body::Stream.
5915    #[tokio::test]
5916    async fn test_stream_body_under_get_not_attached() {
5917        use tower::ServiceExt;
5918
5919        let (url, _handle, captured) = start_capture_server().await;
5920        let ctx = test_producer_ctx();
5921
5922        let component = HttpComponent::with_config(HttpConfig::default());
5923        let endpoint_ctx = NoOpComponentContext;
5924        let endpoint = component
5925            .create_endpoint(
5926                &format!("{url}?httpMethod=GET&allowInternal=true"),
5927                &endpoint_ctx,
5928            )
5929            .unwrap();
5930        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5931
5932        let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5933            vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
5934        let stream = Box::pin(futures::stream::iter(chunks));
5935        let mut exchange = Exchange::new(Message::default());
5936        exchange.input.body = Body::Stream(StreamBody {
5937            stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5938            metadata: StreamMetadata::default(),
5939        });
5940
5941        let result = producer.oneshot(exchange).await.unwrap();
5942
5943        let status = result
5944            .input
5945            .header("CamelHttpResponseCode")
5946            .and_then(|v| v.as_u64())
5947            .unwrap();
5948        assert_eq!(status, 200);
5949
5950        let captured = captured.lock().unwrap();
5951        assert_eq!(captured.len(), 1, "expected exactly one captured request");
5952        assert!(
5953            captured[0].body.is_empty(),
5954            "GET must not carry a stream body"
5955        );
5956        assert!(
5957            captured[0].transfer_encoding.is_none(),
5958            "suppressed request must not carry Transfer-Encoding"
5959        );
5960        assert!(
5961            captured[0].content_length.is_none(),
5962            "suppressed request must not carry Content-Length"
5963        );
5964        assert!(
5965            result.input.body.is_empty(),
5966            "exchange body must be consumed to Empty, not left as a stream"
5967        );
5968    }
5969
5970    /// A suppressed body must never be replayed across 307/308 redirect hops:
5971    /// the gate empties `materialized_body` before the redirect loop runs, so
5972    /// neither the first hop nor the final hop carries the body.
5973    #[tokio::test]
5974    async fn test_redirect_hops_never_replay_suppressed_body() {
5975        use tower::ServiceExt;
5976
5977        let (url, _handle, captured) = start_redirect_capture_server().await;
5978        let ctx = test_producer_ctx();
5979
5980        let component =
5981            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5982        let endpoint_ctx = NoOpComponentContext;
5983
5984        for path in ["/hop307", "/hop308"] {
5985            let endpoint = component
5986                .create_endpoint(
5987                    &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
5988                    &endpoint_ctx,
5989                )
5990                .unwrap();
5991            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5992
5993            let mut exchange = Exchange::new(Message::default());
5994            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5995
5996            let result = producer.oneshot(exchange).await.unwrap();
5997            let status = result
5998                .input
5999                .header("CamelHttpResponseCode")
6000                .and_then(|v| v.as_u64())
6001                .unwrap();
6002            assert_eq!(
6003                status, 200,
6004                "redirect chain for {path} should end at /final"
6005            );
6006        }
6007
6008        // Two chains (307 and 308), each with two hops (redirect + final).
6009        let captured = captured.lock().unwrap();
6010        assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
6011        for req in captured.iter() {
6012            assert!(
6013                req.body.is_empty(),
6014                "hop {} {} must not carry a body",
6015                req.method,
6016                req.path
6017            );
6018        }
6019    }
6020
6021    /// The warn! emitted on a suppressed body renders three distinguishable
6022    /// substrings in the log line (tracing-subscriber default field format):
6023    ///   - the message:       "dropping request body ..."
6024    ///   - `method = %method_str`            → `method=GET`
6025    ///   - `correlation_id = %exchange.correlation_id()` → `correlation_id=<uuid>`
6026    /// The closure matches all three so exactly one warn per suppressed
6027    /// request is required (the "HTTP request" debug! also carries
6028    /// `method=GET` and the same `correlation_id=`, but not the message).
6029    #[tracing_test::traced_test]
6030    #[tokio::test]
6031    async fn test_suppressed_body_logs_exactly_one_warn() {
6032        use tower::ServiceExt;
6033
6034        let (url, _handle, _captured) = start_capture_server().await;
6035        let ctx = test_producer_ctx();
6036
6037        let component = HttpComponent::with_config(HttpConfig::default());
6038        let endpoint_ctx = NoOpComponentContext;
6039        let endpoint = component
6040            .create_endpoint(
6041                &format!("{url}?httpMethod=GET&allowInternal=true"),
6042                &endpoint_ctx,
6043            )
6044            .unwrap();
6045        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6046
6047        let mut exchange = Exchange::new(Message::default());
6048        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6049        let correlation_id = exchange.correlation_id().to_string();
6050
6051        let result = producer.oneshot(exchange).await.unwrap();
6052        let status = result
6053            .input
6054            .header("CamelHttpResponseCode")
6055            .and_then(|v| v.as_u64())
6056            .unwrap();
6057        assert_eq!(status, 200);
6058
6059        logs_assert(|lines: &[&str]| {
6060            let hits = lines
6061                .iter()
6062                .filter(|l| {
6063                    l.contains("dropping request body")
6064                        && l.contains("method=GET")
6065                        && l.contains(&format!("correlation_id={correlation_id}"))
6066                })
6067                .count();
6068            match hits {
6069                1 => Ok(()),
6070                n => Err(format!("expected exactly one body-drop warn, found {n}")),
6071            }
6072        });
6073    }
6074
6075    #[tracing_test::traced_test]
6076    #[tokio::test]
6077    async fn test_empty_body_get_emits_no_warn() {
6078        use tower::ServiceExt;
6079
6080        let (url, _handle, _captured) = start_capture_server().await;
6081        let ctx = test_producer_ctx();
6082
6083        let component = HttpComponent::with_config(HttpConfig::default());
6084        let endpoint_ctx = NoOpComponentContext;
6085        let endpoint = component
6086            .create_endpoint(
6087                &format!("{url}?httpMethod=GET&allowInternal=true"),
6088                &endpoint_ctx,
6089            )
6090            .unwrap();
6091        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6092
6093        let exchange = Exchange::new(Message::default());
6094        let result = producer.oneshot(exchange).await.unwrap();
6095        let status = result
6096            .input
6097            .header("CamelHttpResponseCode")
6098            .and_then(|v| v.as_u64())
6099            .unwrap();
6100        assert_eq!(status, 200);
6101
6102        logs_assert(|lines: &[&str]| {
6103            let hits = lines
6104                .iter()
6105                .filter(|l| l.contains("dropping request body"))
6106                .count();
6107            match hits {
6108                0 => Ok(()),
6109                n => Err(format!("expected no body-drop warn, found {n}")),
6110            }
6111        });
6112    }
6113
6114    #[tokio::test]
6115    async fn test_follow_redirects_false_does_not_follow() {
6116        use tower::ServiceExt;
6117
6118        let (url, _handle) = start_redirect_server().await;
6119        let ctx = test_producer_ctx();
6120
6121        let component =
6122            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
6123        let endpoint_ctx = NoOpComponentContext;
6124        let endpoint = component
6125            .create_endpoint(
6126                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
6127                &endpoint_ctx,
6128            )
6129            .unwrap();
6130        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6131
6132        let exchange = Exchange::new(Message::default());
6133        let result = producer.oneshot(exchange).await.unwrap();
6134
6135        // Should get 302, NOT follow redirect to 200
6136        let status = result
6137            .input
6138            .header("CamelHttpResponseCode")
6139            .and_then(|v| v.as_u64())
6140            .unwrap();
6141        assert_eq!(
6142            status, 302,
6143            "Should NOT follow redirect when followRedirects=false"
6144        );
6145    }
6146
6147    #[tokio::test]
6148    async fn test_follow_redirects_true_follows_redirect() {
6149        use tower::ServiceExt;
6150
6151        let (url, _handle) = start_redirect_server().await;
6152        let ctx = test_producer_ctx();
6153
6154        let component =
6155            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6156        let endpoint_ctx = NoOpComponentContext;
6157        let endpoint = component
6158            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6159            .unwrap();
6160        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6161
6162        let exchange = Exchange::new(Message::default());
6163        let result = producer.oneshot(exchange).await.unwrap();
6164
6165        // Should follow redirect and get 200
6166        let status = result
6167            .input
6168            .header("CamelHttpResponseCode")
6169            .and_then(|v| v.as_u64())
6170            .unwrap();
6171        assert_eq!(
6172            status, 200,
6173            "Should follow redirect when followRedirects=true"
6174        );
6175    }
6176
6177    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
6178    /// This verifies the manual redirect loop executes correctly.
6179    #[tokio::test]
6180    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
6181        use tower::ServiceExt;
6182
6183        // Use the existing redirect server which redirects to /final on the same server
6184        let (url, _handle) = start_redirect_server().await;
6185        let ctx = test_producer_ctx();
6186
6187        let component =
6188            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6189        let endpoint_ctx = NoOpComponentContext;
6190        let endpoint = component
6191            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6192            .unwrap();
6193        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6194
6195        let exchange = Exchange::new(Message::default());
6196        let result = producer.oneshot(exchange).await;
6197
6198        // With allowInternal=true, the redirect should succeed
6199        assert!(
6200            result.is_ok(),
6201            "Redirect should succeed with allowInternal=true, got: {:?}",
6202            result
6203        );
6204        let exchange = result.unwrap();
6205        let status = exchange
6206            .input
6207            .header("CamelHttpResponseCode")
6208            .and_then(|v| v.as_u64())
6209            .unwrap();
6210        assert_eq!(status, 200, "Should follow redirect to /final");
6211    }
6212
6213    /// With allowInternal=true, redirects to private IPs should be followed.
6214    #[tokio::test]
6215    async fn test_redirect_to_private_ip_allowed_when_configured() {
6216        use tower::ServiceExt;
6217
6218        // Start a server that redirects to /final on the same server (127.0.0.1)
6219        let (url, _handle) = start_redirect_server().await;
6220        let ctx = test_producer_ctx();
6221
6222        let component =
6223            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6224        let endpoint_ctx = NoOpComponentContext;
6225        let endpoint = component
6226            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6227            .unwrap();
6228        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6229
6230        let exchange = Exchange::new(Message::default());
6231        let result = producer.oneshot(exchange).await.unwrap();
6232
6233        let status = result
6234            .input
6235            .header("CamelHttpResponseCode")
6236            .and_then(|v| v.as_u64())
6237            .unwrap();
6238        assert_eq!(
6239            status, 200,
6240            "Should follow redirect to private IP when allowInternal=true"
6241        );
6242    }
6243
6244    /// Integration test: with allowInternal=false (default), a redirect to a
6245    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
6246    #[tokio::test]
6247    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
6248        use tower::ServiceExt;
6249
6250        // Server that redirects to the AWS metadata endpoint (link-local private IP)
6251        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6252        let addr = listener.local_addr().unwrap();
6253        let url = format!("http://127.0.0.1:{}", addr.port());
6254
6255        let handle = tokio::spawn(async move {
6256            use tokio::io::{AsyncReadExt, AsyncWriteExt};
6257            loop {
6258                if let Ok((mut stream, _)) = listener.accept().await {
6259                    tokio::spawn(async move {
6260                        let mut buf = vec![0u8; 4096];
6261                        let _ = stream.read(&mut buf).await;
6262                        // Always redirect to the metadata endpoint
6263                        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";
6264                        let _ = stream.write_all(response.as_bytes()).await;
6265                    });
6266                }
6267            }
6268        });
6269
6270        let ctx = test_producer_ctx();
6271        let component =
6272            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6273        let endpoint_ctx = NoOpComponentContext;
6274        // allowInternal=false is the default — do NOT set it
6275        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
6276        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6277
6278        let exchange = Exchange::new(Message::default());
6279        let result = producer.oneshot(exchange).await;
6280
6281        // Must be an error — SSRF guard blocks the redirect target
6282        assert!(
6283            result.is_err(),
6284            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
6285        );
6286        let err = result.unwrap_err().to_string();
6287        assert!(
6288            err.contains("blocked IP")
6289                || err.contains("private IP")
6290                || err.contains("SSRF")
6291                || err.contains("not allowed"),
6292            "Error should mention SSRF/IP blocking, got: {err}"
6293        );
6294
6295        handle.abort();
6296    }
6297
6298    /// Integration test: exceeding maxRedirects produces a clear error.
6299    #[tokio::test]
6300    async fn test_too_many_redirects_returns_error() {
6301        use tower::ServiceExt;
6302
6303        // Server that always redirects to itself (infinite loop)
6304        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6305        let addr = listener.local_addr().unwrap();
6306        let url = format!("http://127.0.0.1:{}", addr.port());
6307
6308        let handle = tokio::spawn(async move {
6309            use tokio::io::{AsyncReadExt, AsyncWriteExt};
6310            loop {
6311                if let Ok((mut stream, _)) = listener.accept().await {
6312                    tokio::spawn(async move {
6313                        let mut buf = vec![0u8; 4096];
6314                        let _ = stream.read(&mut buf).await;
6315                        // Always redirect to /loop
6316                        // Connection: close stops the client pooling the
6317                        // connection the server drops right after this
6318                        // response (pooled-race, rc-u3aw).
6319                        let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
6320                        let _ = stream.write_all(response.as_bytes()).await;
6321                    });
6322                }
6323            }
6324        });
6325
6326        let ctx = test_producer_ctx();
6327        let component =
6328            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6329        let endpoint_ctx = NoOpComponentContext;
6330        let endpoint = component
6331            .create_endpoint(
6332                &format!("{url}?allowInternal=true&maxRedirects=2"),
6333                &endpoint_ctx,
6334            )
6335            .unwrap();
6336        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6337
6338        let exchange = Exchange::new(Message::default());
6339        let result = producer.oneshot(exchange).await;
6340
6341        // With the fix, exceeding max redirects returns the redirect response
6342        // as-is instead of erroring. The 302 redirect response is returned
6343        // after followRedirects exhausts the allowed redirect count (2).
6344        // Disable throwExceptionOnFailure to inspect the raw response status.
6345        //
6346        // Old behavior: Err("Too many redirects (max 2)")
6347        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
6348        match result {
6349            Err(e) => {
6350                // If throw_exception_on_failure is on, we get HttpOperationFailed
6351                let msg = e.to_string();
6352                assert!(
6353                    msg.contains("HTTP operation failed") || msg.contains("302"),
6354                    "expected redirect-after-exhaustion error, got: {msg}"
6355                );
6356            }
6357            Ok(ex) => {
6358                let response_code = ex
6359                    .input
6360                    .header("CamelHttpResponseCode")
6361                    .and_then(|v| v.as_u64());
6362                assert_eq!(
6363                    response_code,
6364                    Some(302),
6365                    "expected 302 after exhausting redirects"
6366                );
6367            }
6368        }
6369
6370        handle.abort();
6371    }
6372
6373    #[tokio::test]
6374    async fn test_query_params_forwarded_to_http_request() {
6375        use tower::ServiceExt;
6376
6377        let (url, _handle) = start_test_server().await;
6378        let ctx = test_producer_ctx();
6379
6380        let component = HttpComponent::new();
6381        let endpoint_ctx = NoOpComponentContext;
6382        // apiKey is NOT a Camel option, should be forwarded as query param
6383        let endpoint = component
6384            .create_endpoint(
6385                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
6386                &endpoint_ctx,
6387            )
6388            .unwrap();
6389        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6390
6391        let exchange = Exchange::new(Message::default());
6392        let result = producer.oneshot(exchange).await.unwrap();
6393
6394        // The test server returns the request info in response
6395        // We just verify it succeeds (the query param was sent)
6396        let status = result
6397            .input
6398            .header("CamelHttpResponseCode")
6399            .and_then(|v| v.as_u64())
6400            .unwrap();
6401        assert_eq!(status, 200);
6402    }
6403
6404    #[test]
6405    fn test_non_camel_query_params_are_forwarded() {
6406        // Authored pairs ride raw_query (the sole carrier); query_params is
6407        // programmatic-only (http-query-wire-fidelity).
6408        let config = HttpEndpointConfig::from_uri(
6409            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
6410        )
6411        .unwrap();
6412
6413        // apiKey and token are NOT camel-http options: the authored bytes
6414        // (including the interleaved httpMethod) ride raw_query verbatim.
6415        assert_eq!(
6416            config.raw_query.as_deref(),
6417            Some("apiKey=secret123&httpMethod=GET&token=abc456")
6418        );
6419        assert!(config.query_params.is_empty());
6420    }
6421
6422    #[test]
6423    fn test_authored_query_bytes_survive_resolve_url() {
6424        let config =
6425            HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
6426        let exchange = Exchange::new(Message::default());
6427
6428        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
6429
6430        // Authored bytes ride verbatim: `%20` stays `%20` (never re-encoded
6431        // to `+` or double-encoded) and `+` stays `+`.
6432        assert!(url.contains("q=hello%20world"), "url was: {url}");
6433        assert!(url.contains("tag=a+b"), "url was: {url}");
6434    }
6435
6436    // -----------------------------------------------------------------------
6437    // Timeout tests (HTTP-004)
6438    // -----------------------------------------------------------------------
6439
6440    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
6441        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6442        let addr = listener.local_addr().unwrap();
6443        let url = format!("http://127.0.0.1:{}", addr.port());
6444
6445        let handle = tokio::spawn(async move {
6446            loop {
6447                if let Ok((mut stream, _)) = listener.accept().await {
6448                    let delay = delay_ms;
6449                    tokio::spawn(async move {
6450                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
6451                        let mut buf = vec![0u8; 4096];
6452                        let _ = stream.read(&mut buf).await;
6453                        // Send headers immediately (no Content-Length → chunked)
6454                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
6455                        let _ = stream.write_all(headers.as_bytes()).await;
6456                        // Delay before sending body chunk
6457                        tokio::time::sleep(Duration::from_millis(delay)).await;
6458                        let body = r#"{"status":"slow"}"#;
6459                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
6460                        let _ = stream.write_all(chunk.as_bytes()).await;
6461                    });
6462                }
6463            }
6464        });
6465
6466        (url, handle)
6467    }
6468
6469    #[tokio::test]
6470    async fn test_http_producer_timeout() {
6471        use tower::ServiceExt;
6472
6473        // Server delays 500ms, client timeout is 100ms → should timeout
6474        let (url, _handle) = start_slow_server(500).await;
6475        let ctx = test_producer_ctx();
6476
6477        let component = HttpComponent::with_config(
6478            HttpConfig::default()
6479                .with_read_timeout_ms(100)
6480                .with_response_timeout_ms(30_000), // generous response timeout
6481        );
6482        let endpoint_ctx = NoOpComponentContext;
6483        let endpoint = component
6484            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
6485            .unwrap();
6486        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6487
6488        let exchange = Exchange::new(Message::default());
6489        let result = producer.oneshot(exchange).await;
6490
6491        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
6492        let err = result.unwrap_err().to_string();
6493        assert!(
6494            err.contains("Read timeout") || err.contains("timeout"),
6495            "Error should mention timeout, got: {}",
6496            err
6497        );
6498    }
6499
6500    #[tokio::test]
6501    async fn test_http_producer_no_timeout_when_fast() {
6502        use tower::ServiceExt;
6503
6504        let (url, _handle) = start_test_server().await;
6505        let ctx = test_producer_ctx();
6506
6507        let component =
6508            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
6509        let endpoint_ctx = NoOpComponentContext;
6510        let endpoint = component
6511            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6512            .unwrap();
6513        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6514
6515        let exchange = Exchange::new(Message::default());
6516        let result = producer.oneshot(exchange).await.unwrap();
6517
6518        let status = result
6519            .input
6520            .header("CamelHttpResponseCode")
6521            .and_then(|v| v.as_u64())
6522            .unwrap();
6523        assert_eq!(status, 200);
6524    }
6525
6526    // -----------------------------------------------------------------------
6527    // SSRF Protection tests
6528    // -----------------------------------------------------------------------
6529
6530    #[tokio::test]
6531    async fn test_http_producer_blocks_metadata_endpoint() {
6532        use tower::ServiceExt;
6533
6534        let ctx = test_producer_ctx();
6535        let component = HttpComponent::new();
6536        let endpoint_ctx = NoOpComponentContext;
6537        let endpoint = component
6538            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
6539            .unwrap();
6540        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6541
6542        let mut exchange = Exchange::new(Message::default());
6543        exchange.input.set_header(
6544            "CamelHttpUri",
6545            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
6546        );
6547
6548        let result = producer.oneshot(exchange).await;
6549        assert!(result.is_err(), "Should block AWS metadata endpoint");
6550
6551        let err = result.unwrap_err();
6552        assert!(
6553            err.to_string().contains("Private IP"),
6554            "Error should mention private IP blocking, got: {}",
6555            err
6556        );
6557    }
6558
6559    #[test]
6560    fn test_ssrf_config_defaults() {
6561        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
6562        assert!(
6563            !config.allow_internal,
6564            "Private IPs should be blocked by default"
6565        );
6566        assert!(
6567            config.blocked_hosts.is_empty(),
6568            "Blocked hosts should be empty by default"
6569        );
6570    }
6571
6572    #[test]
6573    fn test_ssrf_config_allow_internal() {
6574        let config =
6575            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
6576        assert!(
6577            config.allow_internal,
6578            "Private IPs should be allowed when explicitly set"
6579        );
6580    }
6581
6582    #[test]
6583    fn test_ssrf_config_blocked_hosts() {
6584        let config = HttpEndpointConfig::from_uri(
6585            "http://example.com/api?blockedHosts=evil.com,malware.net",
6586        )
6587        .unwrap();
6588        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
6589    }
6590
6591    #[tokio::test]
6592    async fn test_http_producer_blocks_localhost() {
6593        use tower::ServiceExt;
6594
6595        let ctx = test_producer_ctx();
6596        let component = HttpComponent::new();
6597        let endpoint_ctx = NoOpComponentContext;
6598        let endpoint = component
6599            .create_endpoint("http://example.com/api", &endpoint_ctx)
6600            .unwrap();
6601        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6602
6603        let mut exchange = Exchange::new(Message::default());
6604        exchange.input.set_header(
6605            "CamelHttpUri",
6606            serde_json::Value::String("http://localhost:8080/internal".to_string()),
6607        );
6608
6609        let result = producer.oneshot(exchange).await;
6610        assert!(result.is_err(), "Should block localhost");
6611    }
6612
6613    #[tokio::test]
6614    async fn test_http_producer_blocks_loopback_ip() {
6615        use tower::ServiceExt;
6616
6617        let ctx = test_producer_ctx();
6618        let component = HttpComponent::new();
6619        let endpoint_ctx = NoOpComponentContext;
6620        let endpoint = component
6621            .create_endpoint("http://example.com/api", &endpoint_ctx)
6622            .unwrap();
6623        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6624
6625        let mut exchange = Exchange::new(Message::default());
6626        exchange.input.set_header(
6627            "CamelHttpUri",
6628            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
6629        );
6630
6631        let result = producer.oneshot(exchange).await;
6632        assert!(result.is_err(), "Should block loopback IP");
6633    }
6634
6635    #[tokio::test]
6636    async fn test_http_producer_allows_private_ip_when_enabled() {
6637        use tower::ServiceExt;
6638
6639        let ctx = test_producer_ctx();
6640        let component = HttpComponent::new();
6641        let endpoint_ctx = NoOpComponentContext;
6642        // With allowInternal=true, the validation should pass
6643        // (actual connection will fail, but that's expected)
6644        let endpoint = component
6645            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
6646            .unwrap();
6647        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6648
6649        let exchange = Exchange::new(Message::default());
6650
6651        // The request will fail because we can't connect, but it should NOT fail
6652        // due to SSRF protection
6653        let result = producer.oneshot(exchange).await;
6654        // We expect connection error, not SSRF error
6655        if let Err(ref e) = result {
6656            let err_str = e.to_string();
6657            assert!(
6658                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
6659                "Should not be SSRF error, got: {}",
6660                err_str
6661            );
6662        }
6663    }
6664
6665    // -----------------------------------------------------------------------
6666    // HttpServerConfig tests
6667    // -----------------------------------------------------------------------
6668
6669    #[test]
6670    fn test_http_server_config_parse() {
6671        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
6672        assert_eq!(cfg.host, "0.0.0.0");
6673        assert_eq!(cfg.port, 8080);
6674        assert_eq!(cfg.path, "/orders");
6675        assert_eq!(cfg.max_inflight_requests, 1024);
6676    }
6677
6678    #[test]
6679    fn test_http_server_config_scheme() {
6680        // UriConfig trait method returns "http" as primary scheme
6681        assert_eq!(HttpServerConfig::scheme(), "http");
6682    }
6683
6684    #[test]
6685    fn test_http_server_config_from_components() {
6686        // Test from_components directly (trait method)
6687        let components = camel_component_api::UriComponents {
6688            scheme: "https".to_string(),
6689            path: "//0.0.0.0:8443/api".to_string(),
6690            params: std::collections::HashMap::from([
6691                ("maxRequestBody".to_string(), "5242880".to_string()),
6692                ("maxInflightRequests".to_string(), "7".to_string()),
6693            ]),
6694            raw_query: None,
6695        };
6696        let cfg = HttpServerConfig::from_components(components).unwrap();
6697        assert_eq!(cfg.host, "0.0.0.0");
6698        assert_eq!(cfg.port, 8443);
6699        assert_eq!(cfg.path, "/api");
6700        assert_eq!(cfg.max_request_body, 5242880);
6701        assert_eq!(cfg.max_inflight_requests, 7);
6702    }
6703
6704    #[test]
6705    fn test_http_server_config_default_path() {
6706        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
6707        assert_eq!(cfg.path, "/");
6708    }
6709
6710    #[test]
6711    fn test_http_server_config_wrong_scheme() {
6712        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
6713    }
6714
6715    #[test]
6716    fn test_http_server_config_invalid_port() {
6717        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
6718    }
6719
6720    #[test]
6721    fn test_http_server_config_default_port_by_scheme() {
6722        // HTTP without explicit port should default to 80
6723        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
6724        assert_eq!(cfg_http.port, 80);
6725
6726        // HTTPS without explicit port should default to 443
6727        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
6728        assert_eq!(cfg_https.port, 443);
6729    }
6730
6731    #[test]
6732    fn test_request_envelope_and_reply_are_send() {
6733        fn assert_send<T: Send>() {}
6734        assert_send::<RequestEnvelope>();
6735        assert_send::<HttpReply>();
6736    }
6737
6738    // -----------------------------------------------------------------------
6739    // ServerRegistry tests
6740    // -----------------------------------------------------------------------
6741
6742    #[test]
6743    fn test_server_registry_global_is_singleton() {
6744        let r1 = ServerRegistry::global();
6745        let r2 = ServerRegistry::global();
6746        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
6747    }
6748
6749    #[allow(clippy::await_holding_lock)]
6750    #[tokio::test]
6751    async fn test_concurrent_get_or_spawn_returns_same_registry() {
6752        let _guard = lock_registry_test_mutex();
6753        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6754        let port = listener.local_addr().unwrap().port();
6755        drop(listener);
6756
6757        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
6758            Arc::new(std::sync::Mutex::new(Vec::new()));
6759
6760        let mut handles = Vec::new();
6761        for _ in 0..4 {
6762            let results = results.clone();
6763            handles.push(tokio::spawn(async move {
6764                let registry = ServerRegistry::global()
6765                    .get_or_spawn(
6766                        "127.0.0.1",
6767                        port,
6768                        2 * 1024 * 1024,
6769                        10 * 1024 * 1024,
6770                        1024,
6771                        test_rt(),
6772                        "test-route".into(),
6773                        None,
6774                    )
6775                    .await
6776                    .unwrap();
6777                results.lock().unwrap().push(registry);
6778            }));
6779        }
6780
6781        for h in handles {
6782            h.await.unwrap();
6783        }
6784
6785        let registries = results.lock().unwrap();
6786        assert_eq!(registries.len(), 4);
6787        for i in 1..registries.len() {
6788            assert!(
6789                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
6790                "all concurrent callers should get same route registry"
6791            );
6792        }
6793    }
6794
6795    #[test]
6796    fn test_server_registry_distinguishes_host_and_port() {
6797        let _guard = lock_registry_test_mutex();
6798        let rt = tokio::runtime::Runtime::new().expect("runtime");
6799        rt.block_on(async {
6800            let registry = ServerRegistry::global();
6801            // Use two distinct host values with same configured port key.
6802            // Port 0 is acceptable here because the registry key uses the configured
6803            // tuple, not the OS-assigned ephemeral port.
6804            let d1 = registry
6805                .get_or_spawn(
6806                    "127.0.0.1",
6807                    0,
6808                    1024 * 1024,
6809                    10 * 1024 * 1024,
6810                    1024,
6811                    test_rt(),
6812                    "test-route-1".into(),
6813                    None,
6814                )
6815                .await;
6816            let d2 = registry
6817                .get_or_spawn(
6818                    "0.0.0.0",
6819                    0,
6820                    1024 * 1024,
6821                    10 * 1024 * 1024,
6822                    1024,
6823                    test_rt(),
6824                    "test-route-2".into(),
6825                    None,
6826                )
6827                .await;
6828            assert!(d1.is_ok());
6829            assert!(d2.is_ok());
6830            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
6831        });
6832    }
6833
6834    #[allow(clippy::await_holding_lock)]
6835    #[tokio::test]
6836    async fn test_shared_server_max_request_body_policy_is_deterministic() {
6837        let _guard = lock_registry_test_mutex();
6838        let registry = ServerRegistry::global();
6839        // First registration: maxRequestBody = 1 MB
6840        let d1 = registry
6841            .get_or_spawn(
6842                "127.0.0.1",
6843                9991,
6844                1024 * 1024,
6845                10 * 1024 * 1024,
6846                1024,
6847                test_rt(),
6848                "test-route".into(),
6849                None,
6850            )
6851            .await;
6852        assert!(d1.is_ok());
6853
6854        // Second registration on same (host,port): maxRequestBody = 2 MB
6855        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
6856        let d2 = registry
6857            .get_or_spawn(
6858                "127.0.0.1",
6859                9991,
6860                2 * 1024 * 1024,
6861                10 * 1024 * 1024,
6862                1024,
6863                test_rt(),
6864                "test-route-2".into(),
6865                None,
6866            )
6867            .await;
6868        assert!(d2.is_err());
6869        let err = d2.unwrap_err();
6870        assert!(
6871            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
6872            "Expected incompatible maxRequestBody error, got: {}",
6873            err
6874        );
6875    }
6876
6877    #[test]
6878    fn test_server_registry_reset_clears_entries() {
6879        let _guard = lock_registry_test_mutex();
6880        let rt = tokio::runtime::Runtime::new().expect("runtime");
6881        rt.block_on(async {
6882            // Register something on a unique port
6883            let d1 = ServerRegistry::global()
6884                .get_or_spawn(
6885                    "127.0.0.1",
6886                    9992,
6887                    1024 * 1024,
6888                    10 * 1024 * 1024,
6889                    1024,
6890                    test_rt(),
6891                    "test-route".into(),
6892                    None,
6893                )
6894                .await;
6895            assert!(d1.is_ok());
6896
6897            // Verify entry exists
6898            let guard = ServerRegistry::global().inner.lock().expect("lock");
6899            assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
6900            drop(guard);
6901
6902            // Reset
6903            ServerRegistry::reset();
6904
6905            // Verify cleared
6906            let guard = ServerRegistry::global().inner.lock().expect("lock");
6907            assert!(
6908                guard.entries.is_empty(),
6909                "registry should be empty after reset, has {} entries",
6910                guard.entries.len()
6911            );
6912        });
6913    }
6914
6915    #[allow(clippy::await_holding_lock)]
6916    #[tokio::test]
6917    async fn registry_rejects_tls_on_plain_port() {
6918        // httpflake: this reset previously ran WITHOUT the registry test
6919        // mutex, so it could wipe another test's freshly staged entry
6920        // mid-window (traced 2026-09-14) — spec law: every reset caller
6921        // holds REGISTRY_TEST_MUTEX.
6922        let _guard = lock_registry_test_mutex();
6923        ServerRegistry::reset();
6924        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6925
6926        // First route: plain HTTP
6927        let _r1 = ServerRegistry::global()
6928            .get_or_spawn(
6929                "127.0.0.1",
6930                0,
6931                1024,
6932                1024,
6933                16,
6934                Arc::clone(&rt),
6935                "route-1".into(),
6936                None, // plain
6937            )
6938            .await;
6939
6940        // Second route: TLS on same port → must fail
6941        let result = ServerRegistry::global()
6942            .get_or_spawn(
6943                "127.0.0.1",
6944                0,
6945                1024,
6946                1024,
6947                16,
6948                Arc::clone(&rt),
6949                "route-2".into(),
6950                Some(crate::config::ServerTlsConfig {
6951                    cert_path: "/x.pem".into(),
6952                    key_path: "/y.pem".into(),
6953                }),
6954            )
6955            .await;
6956        assert!(result.is_err(), "must reject TLS on plain port");
6957    }
6958
6959    // -----------------------------------------------------------------------
6960    // D-L10: HTTP server is process-lifetime — it survives consumer
6961    // unregister (no refcount; dead servers are evicted on next spawn)
6962    // -----------------------------------------------------------------------
6963
6964    #[allow(clippy::await_holding_lock)]
6965    #[tokio::test]
6966    async fn test_unregister_last_http_route_keeps_server_alive() {
6967        let _guard = lock_registry_test_mutex();
6968        ServerRegistry::reset();
6969        let registry = ServerRegistry::global();
6970
6971        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6972        let port = listener.local_addr().unwrap().port();
6973        drop(listener); // Release — ServerRegistry will rebind
6974        let rt = test_rt();
6975
6976        // Register 2 routes on the same (host, port) — OnceCell returns the
6977        // same ServerHandle.
6978        let _r1 = registry
6979            .get_or_spawn(
6980                "127.0.0.1",
6981                port,
6982                1024 * 1024,
6983                10 * 1024 * 1024,
6984                16,
6985                rt.clone(),
6986                "test-route-1".into(),
6987                None,
6988            )
6989            .await
6990            .unwrap();
6991        let _r2 = registry
6992            .get_or_spawn(
6993                "127.0.0.1",
6994                port,
6995                1024 * 1024,
6996                10 * 1024 * 1024,
6997                16,
6998                rt,
6999                "test-route-2".into(),
7000                None,
7001            )
7002            .await
7003            .unwrap();
7004
7005        let key = ("127.0.0.1".to_string(), port);
7006        let cell = {
7007            let guard = registry.inner.lock().expect("lock");
7008            guard.entries.get(&key).expect("entry should exist").clone()
7009        };
7010
7011        // Unregister first route -> monitor still alive (count = 1).
7012        registry.unregister("127.0.0.1", port).await;
7013        {
7014            let handle = cell
7015                .get()
7016                .expect("handle should still exist after first unregister");
7017            assert!(
7018                !handle.monitor_task.is_finished(),
7019                "monitor task should still be alive after first unregister"
7020            );
7021        }
7022
7023        // Unregister second route -> server stays alive (process-lifetime).
7024        registry.unregister("127.0.0.1", port).await;
7025        tokio::time::sleep(Duration::from_millis(20)).await;
7026        {
7027            let handle = cell
7028                .get()
7029                .expect("handle should still exist after last unregister");
7030            assert!(
7031                !handle.monitor_task.is_finished(),
7032                "monitor task should still be alive — server is process-lifetime"
7033            );
7034        }
7035
7036        // Entry stays in registry for potential restart.
7037        {
7038            let guard = registry.inner.lock().expect("lock");
7039            assert!(
7040                guard.entries.contains_key(&key),
7041                "entry should remain in registry — server kept alive for restart"
7042            );
7043        }
7044    }
7045
7046    // -----------------------------------------------------------------------
7047    // Staged listeners (itest-bound-ports Task 1)
7048    // -----------------------------------------------------------------------
7049
7050    /// CLONE-FIXTURE: bind a std listener on `127.0.0.1:0`, retain a blocking
7051    /// std clone (`probe`) so the port stays reserved, and hand the original
7052    /// socket to tokio as a non-blocking listener. `tokio::net::TcpListener`
7053    /// has no `try_clone`, so clones come from the std handle.
7054    async fn clone_fixture_listener() -> (
7055        tokio::net::TcpListener,
7056        std::net::TcpListener,
7057        std::net::SocketAddr,
7058    ) {
7059        let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
7060        let probe = l.try_clone().expect("clone probe");
7061        l.set_nonblocking(true).expect("set_nonblocking");
7062        let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
7063        let addr = listener.local_addr().expect("local_addr");
7064        (listener, probe, addr)
7065    }
7066
7067    /// Default-limit constants the existing registry tests in this file use.
7068    fn staged_limits() -> (usize, usize, usize) {
7069        (1024 * 1024, 10 * 1024 * 1024, 1024)
7070    }
7071
7072    #[allow(clippy::await_holding_lock)]
7073    #[tokio::test]
7074    async fn staged_listener_first_spawn_serves_without_second_bind() {
7075        let _guard = lock_registry_test_mutex();
7076        ServerRegistry::reset();
7077        let registry = ServerRegistry::global();
7078        let (listener, _probe, addr) = clone_fixture_listener().await;
7079        let port = addr.port();
7080        registry
7081            .stage_listener(listener)
7082            .await
7083            .expect("stage listener");
7084
7085        let (max_req, max_res, max_inflight) = staged_limits();
7086        let routes = registry
7087            .get_or_spawn(
7088                "127.0.0.1",
7089                port,
7090                max_req,
7091                max_res,
7092                max_inflight,
7093                test_rt(),
7094                "staged-first-spawn".into(),
7095                None,
7096            )
7097            .await
7098            .expect("spawn from staged listener must succeed");
7099
7100        assert_eq!(
7101            registry.bound_addr("127.0.0.1", port),
7102            Some(addr),
7103            "served socket must be the staged listener's addr"
7104        );
7105        // The probe clone shares the socket, so service is proven by an HTTP
7106        // response, not by accepting on the probe.
7107        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
7108            .await
7109            .expect("http request against staged listener must connect");
7110        assert!(
7111            resp.status().as_u16() >= 200,
7112            "any status proves the staged socket serves"
7113        );
7114        drop(routes);
7115    }
7116
7117    #[allow(clippy::await_holding_lock)]
7118    #[tokio::test]
7119    async fn staged_entry_reused_by_second_caller() {
7120        let _guard = lock_registry_test_mutex();
7121        ServerRegistry::reset();
7122        let registry = ServerRegistry::global();
7123        let (listener, _probe, addr) = clone_fixture_listener().await;
7124        let port = addr.port();
7125        registry
7126            .stage_listener(listener)
7127            .await
7128            .expect("stage listener");
7129
7130        let (max_req, max_res, max_inflight) = staged_limits();
7131        let first = registry
7132            .get_or_spawn(
7133                "127.0.0.1",
7134                port,
7135                max_req,
7136                max_res,
7137                max_inflight,
7138                test_rt(),
7139                "staged-reuse-1".into(),
7140                None,
7141            )
7142            .await
7143            .expect("first spawn from staged listener");
7144        let second = registry
7145            .get_or_spawn(
7146                "127.0.0.1",
7147                port,
7148                max_req,
7149                max_res,
7150                max_inflight,
7151                test_rt(),
7152                "staged-reuse-2".into(),
7153                None,
7154            )
7155            .await
7156            .expect("second caller must reuse the entry");
7157        assert_eq!(
7158            registry.bound_addr("127.0.0.1", port),
7159            Some(addr),
7160            "entry reused — bound addr unchanged, no second bind"
7161        );
7162        drop(first);
7163        drop(second);
7164    }
7165
7166    #[allow(clippy::await_holding_lock)]
7167    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7168    async fn staged_race_two_callers_single_resolver() {
7169        let _guard = lock_registry_test_mutex();
7170        ServerRegistry::reset();
7171        let registry = ServerRegistry::global();
7172        let (listener, _probe, addr) = clone_fixture_listener().await;
7173        let port = addr.port();
7174        registry
7175            .stage_listener(listener)
7176            .await
7177            .expect("stage listener");
7178
7179        // Two racing callers for the exact staged key: the staged listener
7180        // must be consumed by the single cell-init winner and served to
7181        // both — never leave the winner binding a port the loser still
7182        // holds (EADDRINUSE).
7183        let (max_req, max_res, max_inflight) = staged_limits();
7184        let (first, second) = tokio::join!(
7185            registry.get_or_spawn(
7186                "127.0.0.1",
7187                port,
7188                max_req,
7189                max_res,
7190                max_inflight,
7191                test_rt(),
7192                "staged-race-1".into(),
7193                None,
7194            ),
7195            registry.get_or_spawn(
7196                "127.0.0.1",
7197                port,
7198                max_req,
7199                max_res,
7200                max_inflight,
7201                test_rt(),
7202                "staged-race-2".into(),
7203                None,
7204            ),
7205        );
7206        let first = first.expect("first racing caller must succeed");
7207        let second = second.expect("second racing caller must succeed");
7208        assert_eq!(
7209            registry.bound_addr("127.0.0.1", port),
7210            Some(addr),
7211            "single entry must be served from the staged socket — no EADDRINUSE path"
7212        );
7213        drop(first);
7214        drop(second);
7215    }
7216
7217    #[allow(clippy::await_holding_lock)]
7218    #[tokio::test]
7219    async fn unstaged_spawn_binds_legacy() {
7220        let _guard = lock_registry_test_mutex();
7221        ServerRegistry::reset();
7222        let registry = ServerRegistry::global();
7223        // Fresh port P2: reserve then release — the legacy path rebinds.
7224        let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
7225        let port = probe.local_addr().expect("local addr").port();
7226        drop(probe);
7227
7228        let (max_req, max_res, max_inflight) = staged_limits();
7229        registry
7230            .get_or_spawn(
7231                "127.0.0.1",
7232                port,
7233                max_req,
7234                max_res,
7235                max_inflight,
7236                test_rt(),
7237                "legacy-bind".into(),
7238                None,
7239            )
7240            .await
7241            .expect("legacy bind spawn");
7242        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
7243            .await
7244            .expect("connect to freshly bound port must succeed");
7245        assert!(resp.status().as_u16() >= 200);
7246        assert_eq!(
7247            registry.bound_addr("127.0.0.1", port),
7248            Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
7249            "bound addr must be the legacy bound (host, port)"
7250        );
7251    }
7252
7253    #[allow(clippy::await_holding_lock)]
7254    #[tokio::test]
7255    async fn wrong_host_staged_port_fails_deterministically() {
7256        let _guard = lock_registry_test_mutex();
7257        ServerRegistry::reset();
7258        let registry = ServerRegistry::global();
7259        let (listener, _probe, addr) = clone_fixture_listener().await;
7260        let port = addr.port();
7261        registry
7262            .stage_listener(listener)
7263            .await
7264            .expect("stage listener under 127.0.0.1");
7265
7266        let (max_req, max_res, max_inflight) = staged_limits();
7267        let err = registry
7268            .get_or_spawn(
7269                "localhost",
7270                port,
7271                max_req,
7272                max_res,
7273                max_inflight,
7274                test_rt(),
7275                "conflict-probe".into(),
7276                None,
7277            )
7278            .await
7279            .expect_err("wrong host on staged port must fail deterministically");
7280        assert!(
7281            err.to_string().contains("staged listener conflict on port"),
7282            "unexpected error: {err}"
7283        );
7284
7285        // Slot untouched by the failed call: the correct host now consumes it.
7286        registry
7287            .get_or_spawn(
7288                "127.0.0.1",
7289                port,
7290                max_req,
7291                max_res,
7292                max_inflight,
7293                test_rt(),
7294                "conflict-after".into(),
7295                None,
7296            )
7297            .await
7298            .expect("correct host must serve the staged listener");
7299        assert_eq!(
7300            registry.bound_addr("127.0.0.1", port),
7301            Some(addr),
7302            "staged slot must be untouched by the conflicting call"
7303        );
7304    }
7305
7306    #[allow(clippy::await_holding_lock)]
7307    #[tokio::test]
7308    async fn duplicate_stage_same_key_rejected() {
7309        let _guard = lock_registry_test_mutex();
7310        ServerRegistry::reset();
7311        let registry = ServerRegistry::global();
7312        let (listener, probe, addr) = clone_fixture_listener().await;
7313        registry
7314            .stage_listener(listener)
7315            .await
7316            .expect("stage listener A");
7317
7318        // Second tokio handle to the SAME socket: clone the std probe handle.
7319        let dup = probe.try_clone().expect("clone2");
7320        dup.set_nonblocking(true).expect("set_nonblocking2");
7321        let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
7322
7323        let err = registry
7324            .stage_listener(b)
7325            .await
7326            .expect_err("duplicate stage must be rejected");
7327        assert!(
7328            err.to_string().contains("listener already staged"),
7329            "unexpected error: {err}"
7330        );
7331
7332        let (max_req, max_res, max_inflight) = staged_limits();
7333        registry
7334            .get_or_spawn(
7335                "127.0.0.1",
7336                addr.port(),
7337                max_req,
7338                max_res,
7339                max_inflight,
7340                test_rt(),
7341                "dup-stage-after".into(),
7342                None,
7343            )
7344            .await
7345            .expect("spawn from first staged listener");
7346        assert_eq!(
7347            registry.bound_addr("127.0.0.1", addr.port()),
7348            Some(addr),
7349            "first staged listener retained"
7350        );
7351    }
7352
7353    #[allow(clippy::await_holding_lock)]
7354    #[tokio::test]
7355    async fn distinct_keys_stage_independently() {
7356        let _guard = lock_registry_test_mutex();
7357        ServerRegistry::reset();
7358        let registry = ServerRegistry::global();
7359        let (l1, _p1, addr1) = clone_fixture_listener().await;
7360        let (l2, _p2, addr2) = clone_fixture_listener().await;
7361        registry.stage_listener(l1).await.expect("stage P1");
7362        registry.stage_listener(l2).await.expect("stage P2");
7363
7364        let (max_req, max_res, max_inflight) = staged_limits();
7365        registry
7366            .get_or_spawn(
7367                "127.0.0.1",
7368                addr1.port(),
7369                max_req,
7370                max_res,
7371                max_inflight,
7372                test_rt(),
7373                "distinct-1".into(),
7374                None,
7375            )
7376            .await
7377            .expect("spawn P1");
7378        registry
7379            .get_or_spawn(
7380                "127.0.0.1",
7381                addr2.port(),
7382                max_req,
7383                max_res,
7384                max_inflight,
7385                test_rt(),
7386                "distinct-2".into(),
7387                None,
7388            )
7389            .await
7390            .expect("spawn P2");
7391        assert_eq!(
7392            registry.bound_addr("127.0.0.1", addr1.port()),
7393            Some(addr1),
7394            "P1 bound addr must be its own listener"
7395        );
7396        assert_eq!(
7397            registry.bound_addr("127.0.0.1", addr2.port()),
7398            Some(addr2),
7399            "P2 bound addr must be its own listener"
7400        );
7401        let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
7402            .await
7403            .expect("connect P1");
7404        assert!(r1.status().as_u16() >= 200);
7405        let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
7406            .await
7407            .expect("connect P2");
7408        assert!(r2.status().as_u16() >= 200);
7409    }
7410
7411    #[allow(clippy::await_holding_lock)]
7412    #[tokio::test]
7413    async fn tls_prebound_listener_served() {
7414        use camel_component_api::test_support::tls;
7415
7416        // Install rustls crypto provider (aws-lc-rs — matches the existing
7417        // TLS registry tests).
7418        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7419
7420        let _guard = lock_registry_test_mutex();
7421        ServerRegistry::reset();
7422        let registry = ServerRegistry::global();
7423        let (listener, _probe, addr) = clone_fixture_listener().await;
7424        let port = addr.port();
7425
7426        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7427        let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
7428        let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
7429        let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
7430
7431        let (max_req, max_res, max_inflight) = staged_limits();
7432        let routes = registry
7433            .get_or_spawn_with_listener(
7434                listener,
7435                max_req,
7436                max_res,
7437                max_inflight,
7438                test_rt(),
7439                "staged-tls".into(),
7440                Some(crate::config::ServerTlsConfig {
7441                    cert_path: cert_path.to_string_lossy().into_owned(),
7442                    key_path: key_path.to_string_lossy().into_owned(),
7443                }),
7444            )
7445            .await
7446            .expect("spawn TLS server from pre-bound listener");
7447
7448        // Client with CA cert — REAL verification (no danger_accept_invalid),
7449        // same helper pattern as the existing TLS registry tests.
7450        let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
7451        let client = reqwest::Client::builder()
7452            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
7453            .build()
7454            .expect("build tls client");
7455
7456        let resp = client
7457            .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
7458            .send()
7459            .await
7460            .expect("TLS handshake + request must succeed");
7461        assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
7462        assert_eq!(
7463            registry.bound_addr("127.0.0.1", port),
7464            Some(addr),
7465            "bound addr equals the pre-bound listener addr"
7466        );
7467        drop(routes);
7468    }
7469
7470    #[allow(clippy::await_holding_lock)]
7471    #[tokio::test]
7472    async fn with_listener_direct_spawn_keyed_by_actual_addr() {
7473        let _guard = lock_registry_test_mutex();
7474        ServerRegistry::reset();
7475        let registry = ServerRegistry::global();
7476        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
7477            .await
7478            .expect("bind un-staged listener");
7479        let addr = listener.local_addr().expect("local addr");
7480        let port = addr.port();
7481
7482        let (max_req, max_res, max_inflight) = staged_limits();
7483        registry
7484            .get_or_spawn_with_listener(
7485                listener,
7486                max_req,
7487                max_res,
7488                max_inflight,
7489                test_rt(),
7490                "with-listener".into(),
7491                None,
7492            )
7493            .await
7494            .expect("direct spawn from un-staged listener");
7495        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
7496            .await
7497            .expect("connect on actual port");
7498        assert!(resp.status().as_u16() >= 200);
7499        assert_eq!(
7500            registry.bound_addr("127.0.0.1", port),
7501            Some(addr),
7502            "registry key is the listener's actual port"
7503        );
7504
7505        registry
7506            .get_or_spawn(
7507                "127.0.0.1",
7508                port,
7509                max_req,
7510                max_res,
7511                max_inflight,
7512                test_rt(),
7513                "with-listener-reuse".into(),
7514                None,
7515            )
7516            .await
7517            .expect("legacy caller must reuse the entry");
7518        assert_eq!(
7519            registry.bound_addr("127.0.0.1", port),
7520            Some(addr),
7521            "entry reused — no second bind"
7522        );
7523    }
7524
7525    // -----------------------------------------------------------------------
7526    // Axum dispatch handler tests
7527    // -----------------------------------------------------------------------
7528
7529    #[tokio::test]
7530    async fn test_dispatch_handler_returns_404_for_unknown_path() {
7531        let registry = HttpRouteRegistry::new();
7532        // Nothing registered in route registry
7533        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7534        let port = listener.local_addr().unwrap().port();
7535        tokio::spawn(run_axum_server(
7536            listener,
7537            registry,
7538            2 * 1024 * 1024,
7539            10 * 1024 * 1024,
7540            Arc::new(tokio::sync::Semaphore::new(1024)),
7541            test_rt(),
7542            "test-route".into(),
7543        ));
7544
7545        // Wait for server to start
7546        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7547
7548        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
7549            .await
7550            .unwrap();
7551        assert_eq!(resp.status().as_u16(), 404);
7552    }
7553
7554    // -----------------------------------------------------------------------
7555    // HttpConsumer tests
7556    // -----------------------------------------------------------------------
7557
7558    #[tokio::test]
7559    async fn test_http_consumer_start_registers_path() {
7560        use camel_component_api::ConsumerContext;
7561
7562        // Get an OS-assigned free port
7563        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7564        let port = listener.local_addr().unwrap().port();
7565        drop(listener); // Release port — ServerRegistry will rebind it
7566
7567        let consumer_cfg = HttpServerConfig {
7568            scheme: "http".to_string(),
7569            host: "127.0.0.1".to_string(),
7570            port,
7571            path: "/ping".to_string(),
7572            max_request_body: 2 * 1024 * 1024,
7573            max_response_body: 10 * 1024 * 1024,
7574            max_inflight_requests: 1024,
7575            method: None,
7576            tls_config: None,
7577        };
7578        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7579
7580        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7581        let token = tokio_util::sync::CancellationToken::new();
7582        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7583
7584        tokio::spawn(async move {
7585            consumer.start(ctx).await.unwrap();
7586        });
7587
7588        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7589
7590        let client = reqwest::Client::new();
7591        let resp_future = client
7592            .post(format!("http://127.0.0.1:{port}/ping"))
7593            .body("hello world")
7594            .send();
7595
7596        let (http_result, _) = tokio::join!(resp_future, async {
7597            if let Some(mut envelope) = rx.recv().await {
7598                // Set a custom status code
7599                envelope.exchange.input.set_header(
7600                    "CamelHttpResponseCode",
7601                    serde_json::Value::Number(201.into()),
7602                );
7603                if let Some(reply_tx) = envelope.reply_tx {
7604                    let _ = reply_tx.send(Ok(envelope.exchange));
7605                }
7606            }
7607        });
7608
7609        let resp = http_result.unwrap();
7610        assert_eq!(resp.status().as_u16(), 201);
7611
7612        token.cancel();
7613    }
7614
7615    /// rc-3y6j: the RequestEnvelope channel capacity must mirror the
7616    /// dispatcher's inflight semaphore so the semaphore stays the single
7617    /// backpressure point. Zero maps to 1 because `mpsc::channel(0)` panics.
7618    #[test]
7619    fn test_envelope_channel_capacity_follows_max_inflight() {
7620        assert_eq!(envelope_channel_capacity(0), 1);
7621        assert_eq!(envelope_channel_capacity(1), 1);
7622        assert_eq!(envelope_channel_capacity(7), 7);
7623        assert_eq!(envelope_channel_capacity(64), 64);
7624        assert_eq!(envelope_channel_capacity(1024), 1024);
7625    }
7626
7627    /// rc-3y6j: `maxInflightRequests=0` is a representable reject-everything
7628    /// configuration. Consumer start must not panic on it (the channel guard)
7629    /// and every request must get 503 from the empty semaphore.
7630    #[tokio::test]
7631    async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
7632        use camel_component_api::ConsumerContext;
7633
7634        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7635        let port = listener.local_addr().unwrap().port();
7636        drop(listener);
7637
7638        let consumer_cfg = HttpServerConfig {
7639            scheme: "http".to_string(),
7640            host: "127.0.0.1".to_string(),
7641            port,
7642            path: "/ping".to_string(),
7643            max_request_body: 2 * 1024 * 1024,
7644            max_response_body: 10 * 1024 * 1024,
7645            max_inflight_requests: 0,
7646            method: None,
7647            tls_config: None,
7648        };
7649        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7650
7651        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7652        let token = tokio_util::sync::CancellationToken::new();
7653        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7654
7655        let start_handle = tokio::spawn(async move {
7656            consumer.start(ctx).await.unwrap();
7657        });
7658
7659        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7660
7661        let client = reqwest::Client::new();
7662        let resp = client
7663            .post(format!("http://127.0.0.1:{port}/ping"))
7664            .body("hello world")
7665            .send()
7666            .await
7667            .unwrap();
7668        assert_eq!(resp.status().as_u16(), 503);
7669
7670        token.cancel();
7671        let _ = start_handle.await;
7672    }
7673
7674    /// rc-w1u9: HttpConsumer MUST declare Explicit startup_mode so the runtime
7675    /// waits for the listener bind before publishing RouteStarted.
7676    #[test]
7677    fn test_http_consumer_startup_mode_is_explicit() {
7678        use camel_component_api::ConsumerStartupMode;
7679        let consumer_cfg = HttpServerConfig {
7680            scheme: "http".to_string(),
7681            host: "127.0.0.1".to_string(),
7682            port: 0,
7683            path: "/x".to_string(),
7684            max_request_body: 2 * 1024 * 1024,
7685            max_response_body: 10 * 1024 * 1024,
7686            max_inflight_requests: 1024,
7687            method: None,
7688            tls_config: None,
7689        };
7690        let consumer = HttpConsumer::new(consumer_cfg, test_rt());
7691        assert_eq!(
7692            consumer.startup_mode(),
7693            ConsumerStartupMode::Explicit,
7694            "HttpConsumer must opt into Explicit startup"
7695        );
7696    }
7697
7698    /// rc-w1u9: HttpConsumer::start() MUST call ctx.mark_ready() AFTER bind
7699    /// + route registration. The StartupSignal resolves Ok only when that
7700    /// happens. Verified here by injecting our own signal pair into the
7701    /// ConsumerContext and asserting the receiver resolves within a bounded
7702    /// window even before any HTTP request is made.
7703    #[allow(clippy::await_holding_lock)]
7704    #[tokio::test]
7705    async fn test_http_consumer_emits_mark_ready_after_bind() {
7706        use camel_component_api::{ConsumerContext, StartupSignal};
7707
7708        let _guard = lock_registry_test_mutex();
7709
7710        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7711        let port = listener.local_addr().unwrap().port();
7712        drop(listener);
7713
7714        let consumer_cfg = HttpServerConfig {
7715            scheme: "http".to_string(),
7716            host: "127.0.0.1".to_string(),
7717            port,
7718            path: "/ready-probe".to_string(),
7719            max_request_body: 2 * 1024 * 1024,
7720            max_response_body: 10 * 1024 * 1024,
7721            max_inflight_requests: 1024,
7722            method: None,
7723            tls_config: None,
7724        };
7725        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7726
7727        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7728        let token = tokio_util::sync::CancellationToken::new();
7729        let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
7730
7731        // Inject our own startup signal so we can observe mark_ready.
7732        let (signal, startup_rx) = StartupSignal::pair();
7733        let ctx = ctx.with_startup(signal);
7734
7735        // Spawn start() — it MUST call mark_ready once the listener is bound
7736        // and the path is registered.
7737        tokio::spawn(async move {
7738            let _ = consumer.start(ctx).await;
7739        });
7740
7741        // The receiver MUST resolve Ok within a bounded window — proving
7742        // mark_ready was called by start(). A short timeout catches the
7743        // regression where mark_ready is never called (the old behaviour
7744        // would hang the receiver forever, which is exactly the rc-w1u9 bug).
7745        let result =
7746            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
7747                .await
7748                .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
7749        assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
7750
7751        // Cancellation tears down the spawned start() loop.
7752        token.cancel();
7753    }
7754
7755    // -----------------------------------------------------------------------
7756    // Shared-server death supervision (rc-szmob / ADR-0007)
7757    // -----------------------------------------------------------------------
7758
7759    /// RuntimeObservability stub that records every `increment_errors`
7760    /// `(route_id, label)` pair so tests can assert error counters.
7761    #[derive(Default, Clone)]
7762    struct ErrorRecordingRuntime {
7763        errors: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
7764    }
7765
7766    impl camel_api::MetricsCollector for ErrorRecordingRuntime {
7767        fn record_exchange_duration(&self, _route_id: &str, _duration: std::time::Duration) {}
7768        fn increment_errors(&self, route_id: &str, error_type: &str) {
7769            self.errors
7770                .lock()
7771                .expect("error recorder lock")
7772                .push((route_id.to_string(), error_type.to_string()));
7773        }
7774        fn increment_exchanges(&self, _route_id: &str) {}
7775        fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
7776        fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
7777    }
7778
7779    impl camel_component_api::HealthCheckRegistry for ErrorRecordingRuntime {
7780        fn force_unhealthy_for_route(&self, _route_id: &str, _name: &str, _reason: &str) {}
7781    }
7782
7783    impl camel_component_api::RuntimeObservability for ErrorRecordingRuntime {
7784        fn metrics(&self) -> std::sync::Arc<dyn camel_api::MetricsCollector> {
7785            std::sync::Arc::new(self.clone())
7786        }
7787        fn health(&self) -> std::sync::Arc<dyn camel_component_api::HealthCheckRegistry> {
7788            std::sync::Arc::new(self.clone())
7789        }
7790    }
7791
7792    /// rc-szmob (ADR-0007 parity): when the shared Axum server task for a
7793    /// host:port dies, EVERY HttpConsumer hosted on that port must fail its
7794    /// `start()` with an Err — that Err is the signal camel-core's consumer
7795    /// watcher turns into a per-route CrashNotification → FailRoute →
7796    /// supervision backoff restart. Before the fix the consumers hung in
7797    /// `Running` forever (zombie routes): neither `ctx.cancelled()` nor
7798    /// `env_rx.recv()` fires when the server task dies, because the envelope
7799    /// senders live in the (still-alive) registry, not in the dead task.
7800    ///
7801    /// Deterministic by construction: readiness is awaited via the injected
7802    /// StartupSignal (no sleeps), the server is killed via its AbortHandle
7803    /// (real JoinError → monitor's unexpected-exit branch), and consumer
7804    /// resolution is bounded by a timeout — on unmodified behavior the
7805    /// timeout trips, which is exactly the zombie this test pins down.
7806    #[allow(clippy::await_holding_lock)]
7807    #[tokio::test]
7808    async fn shared_server_death_fails_every_hosted_consumer() {
7809        use camel_component_api::{ConsumerContext, StartupSignal};
7810
7811        let _guard = lock_registry_test_mutex();
7812        ServerRegistry::reset();
7813
7814        // Reserve a port, release it, let get_or_spawn bind it.
7815        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7816        let port = listener.local_addr().unwrap().port();
7817        drop(listener);
7818
7819        let rt = ErrorRecordingRuntime::default();
7820
7821        let make_consumer = |path: &str| {
7822            HttpConsumer::new(
7823                HttpServerConfig {
7824                    scheme: "http".to_string(),
7825                    host: "127.0.0.1".to_string(),
7826                    port,
7827                    path: path.to_string(),
7828                    max_request_body: 2 * 1024 * 1024,
7829                    max_response_body: 10 * 1024 * 1024,
7830                    max_inflight_requests: 16,
7831                    method: None,
7832                    tls_config: None,
7833                },
7834                std::sync::Arc::new(rt.clone()),
7835            )
7836        };
7837
7838        let spawn_consumer = |path: &str, route_id: &str| {
7839            let mut consumer = make_consumer(path);
7840            let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7841            let token = tokio_util::sync::CancellationToken::new();
7842            let ctx = ConsumerContext::new(tx, token, route_id.to_string());
7843            let (signal, startup_rx) = StartupSignal::pair();
7844            let ctx = ctx.with_startup(signal);
7845            let task = tokio::spawn(async move { consumer.start(ctx).await });
7846            (task, startup_rx)
7847        };
7848
7849        // Two routes hosted on the SAME shared server (same host:port).
7850        let (task_a, ready_a) = spawn_consumer("/zombie-a", "zombie-route-a");
7851        let (task_b, ready_b) = spawn_consumer("/zombie-b", "zombie-route-b");
7852
7853        // Both consumers registered and the server is up (bounded, no sleeps).
7854        for (name, ready) in [("a", ready_a), ("b", ready_b)] {
7855            let result =
7856                tokio::time::timeout(std::time::Duration::from_secs(2), ready.await_ready())
7857                    .await
7858                    .unwrap_or_else(|_| panic!("consumer {name} never became ready"));
7859            assert!(
7860                result.is_ok(),
7861                "consumer {name} readiness must resolve Ok (bind + registration complete)"
7862            );
7863        }
7864
7865        // Kill the shared server task: abort → JoinError → the monitor's
7866        // unexpected-exit branch. This is the real crash path (no mock).
7867        {
7868            let registry = ServerRegistry::global();
7869            let guard = registry.inner.lock().expect("ServerRegistry lock");
7870            let cell = guard
7871                .entries
7872                .get(&("127.0.0.1".to_string(), port))
7873                .expect("shared server entry must exist");
7874            let handle = cell.get().expect("server handle must be initialized");
7875            handle.server_abort.abort();
7876        }
7877
7878        // THE assertion: both hosted consumers must fail (bounded). On the
7879        // zombie bug they never resolve and this timeout trips.
7880        let outcome_a = tokio::time::timeout(std::time::Duration::from_secs(2), task_a)
7881            .await
7882            .expect("ZOMBIE: consumer-a still running after shared server death (rc-szmob)");
7883        let outcome_b = tokio::time::timeout(std::time::Duration::from_secs(2), task_b)
7884            .await
7885            .expect("ZOMBIE: consumer-b still running after shared server death (rc-szmob)");
7886
7887        let err_a = outcome_a
7888            .expect("consumer-a task must join")
7889            .expect_err("consumer-a start() must return Err when the shared server dies");
7890        let err_b = outcome_b
7891            .expect("consumer-b task must join")
7892            .expect_err("consumer-b start() must return Err when the shared server dies");
7893
7894        // The error must identify the dead shared transport (it flows into the
7895        // CrashNotification message camel-core records against the route).
7896        for (name, err) in [("a", &err_a), ("b", &err_b)] {
7897            assert!(
7898                err.to_string().contains("127.0.0.1")
7899                    && err.to_string().contains(&port.to_string()),
7900                "consumer-{name} error must name the dead shared server, got: {err}"
7901            );
7902        }
7903
7904        // Error counter regression guard: the monitor still records
7905        // `e:http:server-task-exited` for the route that spawned the server.
7906        let recorded = rt.errors.lock().expect("error recorder lock").clone();
7907        assert!(
7908            recorded
7909                .iter()
7910                .any(|(route, label)| label == "e:http:server-task-exited"
7911                    && route == "zombie-route-a"),
7912            "expected e:http:server-task-exited for the spawning route, got: {recorded:?}"
7913        );
7914    }
7915
7916    #[tokio::test]
7917    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
7918        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7919
7920        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7921        let port = listener.local_addr().unwrap().port();
7922        drop(listener);
7923
7924        let consumer_cfg = HttpServerConfig {
7925            scheme: "http".to_string(),
7926            host: "127.0.0.1".to_string(),
7927            port,
7928            path: "/saturation".to_string(),
7929            max_request_body: 2 * 1024 * 1024,
7930            max_response_body: 10 * 1024 * 1024,
7931            max_inflight_requests: 1,
7932            method: None,
7933            tls_config: None,
7934        };
7935        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7936
7937        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7938        let token = tokio_util::sync::CancellationToken::new();
7939        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7940        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7941        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7942
7943        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
7944        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
7945
7946        tokio::spawn(async move {
7947            let mut first_seen_tx = Some(first_seen_tx);
7948            let mut unblock_first_rx = Some(unblock_first_rx);
7949
7950            while let Some(envelope) = rx.recv().await {
7951                if let Some(tx) = first_seen_tx.take() {
7952                    let _ = tx.send(());
7953                    if let Some(rx_unblock) = unblock_first_rx.take() {
7954                        let _ = rx_unblock.await;
7955                    }
7956                }
7957
7958                if let Some(reply_tx) = envelope.reply_tx {
7959                    let _ = reply_tx.send(Ok(envelope.exchange));
7960                }
7961            }
7962        });
7963
7964        let client = reqwest::Client::new();
7965        let first_req = {
7966            let client = client.clone();
7967            async move {
7968                client
7969                    .get(format!("http://127.0.0.1:{port}/saturation"))
7970                    .send()
7971                    .await
7972                    .unwrap()
7973            }
7974        };
7975
7976        let first_handle = tokio::spawn(first_req);
7977        first_seen_rx.await.unwrap();
7978
7979        let second_resp = client
7980            .get(format!("http://127.0.0.1:{port}/saturation"))
7981            .send()
7982            .await
7983            .unwrap();
7984
7985        assert_eq!(second_resp.status().as_u16(), 503);
7986
7987        let _ = unblock_first_tx.send(());
7988        let first_resp = first_handle.await.unwrap();
7989        assert_eq!(first_resp.status().as_u16(), 200);
7990
7991        token.cancel();
7992    }
7993
7994    /// Audit 2026-08-31, F2-1: a chunked (no Content-Length) request body must
7995    /// still be capped — the byte limit travels with the stream, so any
7996    /// downstream materialization fails closed past `max_request_body`.
7997    #[tokio::test]
7998    async fn test_http_consumer_chunked_body_is_capped() {
7999        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8000
8001        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8002        let port = listener.local_addr().unwrap().port();
8003        drop(listener);
8004
8005        let consumer_cfg = HttpServerConfig {
8006            scheme: "http".to_string(),
8007            host: "127.0.0.1".to_string(),
8008            port,
8009            path: "/chunked-cap".to_string(),
8010            max_request_body: 1024, // tiny cap for the test
8011            max_response_body: 10 * 1024 * 1024,
8012            max_inflight_requests: 16,
8013            method: None,
8014            tls_config: None,
8015        };
8016        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8017
8018        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8019        let token = tokio_util::sync::CancellationToken::new();
8020        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8021        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8022        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8023
8024        // Chunked body: reqwest streams it without Content-Length.
8025        let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
8026            .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
8027            .collect();
8028        let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
8029
8030        let client = reqwest::Client::new();
8031        let send_fut = client
8032            .post(format!("http://127.0.0.1:{port}/chunked-cap"))
8033            .body(stream_body)
8034            .send();
8035
8036        let (http_result, _) = tokio::join!(send_fut, async {
8037            if let Some(mut envelope) = rx.recv().await {
8038                // The route materializes the body — the cap must fire.
8039                let materialized = envelope
8040                    .exchange
8041                    .input
8042                    .body
8043                    .clone()
8044                    .into_bytes(64 * 1024)
8045                    .await;
8046                assert!(
8047                    materialized.is_err(),
8048                    "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
8049                );
8050                let err = materialized.unwrap_err().to_string();
8051                assert!(
8052                    err.contains("limit") || err.contains("exceeds"),
8053                    "error should mention the limit: {err}"
8054                );
8055                if let Some(reply_tx) = envelope.reply_tx {
8056                    envelope.exchange.input.body =
8057                        camel_component_api::Body::Text("handled".to_string());
8058                    let _ = reply_tx.send(Ok(envelope.exchange));
8059                }
8060            }
8061        });
8062
8063        let resp = http_result.unwrap();
8064        assert_eq!(resp.status().as_u16(), 200);
8065
8066        token.cancel();
8067    }
8068
8069    #[tokio::test]
8070    #[allow(clippy::await_holding_lock)]
8071    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
8072        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8073
8074        let _guard = lock_registry_test_mutex();
8075
8076        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8077        let port = listener.local_addr().unwrap().port();
8078        drop(listener);
8079
8080        let consumer_cfg = HttpServerConfig {
8081            scheme: "http".to_string(),
8082            host: "127.0.0.1".to_string(),
8083            port,
8084            path: "/limit-bytes".to_string(),
8085            max_request_body: 2 * 1024 * 1024,
8086            max_response_body: 16,
8087            max_inflight_requests: 1024,
8088            method: None,
8089            tls_config: None,
8090        };
8091        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8092
8093        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8094        let token = tokio_util::sync::CancellationToken::new();
8095        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8096        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8097        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8098
8099        let client = reqwest::Client::new();
8100        let send_fut = client
8101            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
8102            .send();
8103
8104        let (http_result, _) = tokio::join!(send_fut, async {
8105            if let Some(mut envelope) = rx.recv().await {
8106                envelope.exchange.input.body =
8107                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
8108                if let Some(reply_tx) = envelope.reply_tx {
8109                    let _ = reply_tx.send(Ok(envelope.exchange));
8110                }
8111            }
8112        });
8113
8114        let resp = http_result.unwrap();
8115        assert_eq!(resp.status().as_u16(), 500);
8116        let body = resp.text().await.unwrap();
8117        assert_eq!(body, "Response body exceeds configured limit");
8118        token.cancel();
8119    }
8120
8121    #[tokio::test]
8122    #[allow(clippy::await_holding_lock)]
8123    async fn test_http_consumer_enforces_max_response_body_for_json() {
8124        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8125
8126        let _guard = lock_registry_test_mutex();
8127
8128        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8129        let port = listener.local_addr().unwrap().port();
8130        drop(listener);
8131
8132        let consumer_cfg = HttpServerConfig {
8133            scheme: "http".to_string(),
8134            host: "127.0.0.1".to_string(),
8135            port,
8136            path: "/limit-json".to_string(),
8137            max_request_body: 2 * 1024 * 1024,
8138            max_response_body: 16,
8139            max_inflight_requests: 1024,
8140            method: None,
8141            tls_config: None,
8142        };
8143        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8144
8145        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8146        let token = tokio_util::sync::CancellationToken::new();
8147        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8148        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8149        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8150
8151        let client = reqwest::Client::new();
8152        let send_fut = client
8153            .get(format!("http://127.0.0.1:{port}/limit-json"))
8154            .send();
8155
8156        let (http_result, _) = tokio::join!(send_fut, async {
8157            if let Some(mut envelope) = rx.recv().await {
8158                envelope.exchange.input.body = camel_component_api::Body::Json(
8159                    serde_json::json!({"message":"this response is bigger than sixteen"}),
8160                );
8161                if let Some(reply_tx) = envelope.reply_tx {
8162                    let _ = reply_tx.send(Ok(envelope.exchange));
8163                }
8164            }
8165        });
8166
8167        let resp = http_result.unwrap();
8168        assert_eq!(resp.status().as_u16(), 500);
8169        let body = resp.text().await.unwrap();
8170        assert_eq!(body, "Response body exceeds configured limit");
8171        token.cancel();
8172    }
8173
8174    #[tokio::test]
8175    #[allow(clippy::await_holding_lock)]
8176    async fn test_http_consumer_enforces_max_response_body_for_xml() {
8177        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8178
8179        let _guard = lock_registry_test_mutex();
8180
8181        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8182        let port = listener.local_addr().unwrap().port();
8183        drop(listener);
8184
8185        let consumer_cfg = HttpServerConfig {
8186            scheme: "http".to_string(),
8187            host: "127.0.0.1".to_string(),
8188            port,
8189            path: "/limit-xml".to_string(),
8190            max_request_body: 2 * 1024 * 1024,
8191            max_response_body: 16,
8192            max_inflight_requests: 1024,
8193            method: None,
8194            tls_config: None,
8195        };
8196        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8197
8198        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8199        let token = tokio_util::sync::CancellationToken::new();
8200        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8201        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8202        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8203
8204        let client = reqwest::Client::new();
8205        let send_fut = client
8206            .get(format!("http://127.0.0.1:{port}/limit-xml"))
8207            .send();
8208
8209        let (http_result, _) = tokio::join!(send_fut, async {
8210            if let Some(mut envelope) = rx.recv().await {
8211                envelope.exchange.input.body = camel_component_api::Body::Xml(
8212                    "<root><value>way-too-large</value></root>".into(),
8213                );
8214                if let Some(reply_tx) = envelope.reply_tx {
8215                    let _ = reply_tx.send(Ok(envelope.exchange));
8216                }
8217            }
8218        });
8219
8220        let resp = http_result.unwrap();
8221        assert_eq!(resp.status().as_u16(), 500);
8222        let body = resp.text().await.unwrap();
8223        assert_eq!(body, "Response body exceeds configured limit");
8224        token.cancel();
8225    }
8226
8227    #[tokio::test]
8228    #[allow(clippy::await_holding_lock)]
8229    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
8230        use camel_component_api::{
8231            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
8232        };
8233        use futures::stream;
8234
8235        let _guard = lock_registry_test_mutex();
8236
8237        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
8238        let port = listener.local_addr().unwrap().port();
8239        drop(listener);
8240
8241        let consumer_cfg = HttpServerConfig {
8242            scheme: "http".to_string(),
8243            host: "0.0.0.0".to_string(),
8244            port,
8245            path: "/limit-stream".to_string(),
8246            max_request_body: 2 * 1024 * 1024,
8247            max_response_body: 16,
8248            max_inflight_requests: 1024,
8249            method: None,
8250            tls_config: None,
8251        };
8252        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8253
8254        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8255        let token = tokio_util::sync::CancellationToken::new();
8256        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8257        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8258        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8259
8260        let client = reqwest::Client::new();
8261        let send_fut = client
8262            .get(format!("http://127.0.0.1:{port}/limit-stream"))
8263            .send();
8264
8265        let (http_result, _) = tokio::join!(send_fut, async {
8266            if let Some(mut envelope) = rx.recv().await {
8267                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
8268                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
8269                let stream = Box::pin(stream::iter(chunks));
8270                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
8271                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
8272                    metadata: StreamMetadata {
8273                        size_hint: Some(32),
8274                        content_type: Some("application/octet-stream".into()),
8275                        origin: None,
8276                    },
8277                });
8278                if let Some(reply_tx) = envelope.reply_tx {
8279                    let _ = reply_tx.send(Ok(envelope.exchange));
8280                }
8281            }
8282        });
8283
8284        let resp = http_result.unwrap();
8285        assert_eq!(resp.status().as_u16(), 200);
8286        let body = resp.bytes().await.unwrap();
8287        assert_eq!(body.len(), 32);
8288        token.cancel();
8289    }
8290
8291    // -----------------------------------------------------------------------
8292    // Integration tests
8293    // -----------------------------------------------------------------------
8294
8295    #[tokio::test]
8296    #[allow(clippy::await_holding_lock)]
8297    async fn test_integration_single_consumer_round_trip() {
8298        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8299
8300        // Spawns an HTTP consumer on the global ServerRegistry
8301        // (HttpConsumer::start → get_or_spawn). Serialize against the other
8302        // registry tests so parallel runs do not race on shared global state.
8303        let _guard = lock_registry_test_mutex();
8304
8305        // Get an OS-assigned free port (ephemeral)
8306        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8307        let port = listener.local_addr().unwrap().port();
8308        drop(listener); // Release — ServerRegistry will rebind
8309
8310        let component = HttpComponent::new();
8311        let endpoint_ctx = NoOpComponentContext;
8312        let endpoint = component
8313            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
8314            .unwrap();
8315        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8316
8317        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8318        let token = tokio_util::sync::CancellationToken::new();
8319        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8320
8321        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8322        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8323
8324        let client = reqwest::Client::new();
8325        let send_fut = client
8326            .post(format!("http://127.0.0.1:{port}/echo"))
8327            .header("Content-Type", "text/plain")
8328            .body("ping")
8329            .send();
8330
8331        let (http_result, _) = tokio::join!(send_fut, async {
8332            if let Some(mut envelope) = rx.recv().await {
8333                assert_eq!(
8334                    envelope.exchange.input.header("CamelHttpMethod"),
8335                    Some(&serde_json::Value::String("POST".into()))
8336                );
8337                assert_eq!(
8338                    envelope.exchange.input.header("CamelHttpPath"),
8339                    Some(&serde_json::Value::String("/echo".into()))
8340                );
8341                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
8342                if let Some(reply_tx) = envelope.reply_tx {
8343                    let _ = reply_tx.send(Ok(envelope.exchange));
8344                }
8345            }
8346        });
8347
8348        let resp = http_result.unwrap();
8349        assert_eq!(resp.status().as_u16(), 200);
8350        let body = resp.text().await.unwrap();
8351        assert_eq!(body, "pong");
8352
8353        token.cancel();
8354    }
8355
8356    #[tokio::test]
8357    #[allow(clippy::await_holding_lock)]
8358    async fn test_integration_two_consumers_shared_port() {
8359        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8360
8361        let _guard = lock_registry_test_mutex();
8362
8363        // Get an OS-assigned free port (ephemeral)
8364        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8365        let port = listener.local_addr().unwrap().port();
8366        drop(listener);
8367
8368        let component = HttpComponent::new();
8369        let endpoint_ctx = NoOpComponentContext;
8370
8371        // Consumer A: /hello
8372        let endpoint_a = component
8373            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
8374            .unwrap();
8375        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
8376
8377        // Consumer B: /world
8378        let endpoint_b = component
8379            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
8380            .unwrap();
8381        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
8382
8383        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8384        let token_a = tokio_util::sync::CancellationToken::new();
8385        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
8386
8387        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8388        let token_b = tokio_util::sync::CancellationToken::new();
8389        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
8390
8391        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
8392        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
8393        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8394
8395        let client = reqwest::Client::new();
8396
8397        // Request to /hello
8398        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
8399        let (resp_hello, _) = tokio::join!(fut_hello, async {
8400            if let Some(mut envelope) = rx_a.recv().await {
8401                envelope.exchange.input.body =
8402                    camel_component_api::Body::Text("hello-response".to_string());
8403                if let Some(reply_tx) = envelope.reply_tx {
8404                    let _ = reply_tx.send(Ok(envelope.exchange));
8405                }
8406            }
8407        });
8408
8409        // Request to /world
8410        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
8411        let (resp_world, _) = tokio::join!(fut_world, async {
8412            if let Some(mut envelope) = rx_b.recv().await {
8413                envelope.exchange.input.body =
8414                    camel_component_api::Body::Text("world-response".to_string());
8415                if let Some(reply_tx) = envelope.reply_tx {
8416                    let _ = reply_tx.send(Ok(envelope.exchange));
8417                }
8418            }
8419        });
8420
8421        let body_a = resp_hello.unwrap().text().await.unwrap();
8422        let body_b = resp_world.unwrap().text().await.unwrap();
8423
8424        assert_eq!(body_a, "hello-response");
8425        assert_eq!(body_b, "world-response");
8426
8427        token_a.cancel();
8428        token_b.cancel();
8429    }
8430
8431    #[tokio::test]
8432    #[allow(clippy::await_holding_lock)]
8433    async fn test_integration_unregistered_path_returns_404() {
8434        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8435
8436        let _guard = lock_registry_test_mutex();
8437
8438        // Get an OS-assigned free port (ephemeral)
8439        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8440        let port = listener.local_addr().unwrap().port();
8441        drop(listener);
8442
8443        let component = HttpComponent::new();
8444        let endpoint_ctx = NoOpComponentContext;
8445        let endpoint = component
8446            .create_endpoint(
8447                &format!("http://127.0.0.1:{port}/registered"),
8448                &endpoint_ctx,
8449            )
8450            .unwrap();
8451        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8452
8453        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8454        let token = tokio_util::sync::CancellationToken::new();
8455        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8456
8457        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8458
8459        // Wait until the server is actually accepting connections (CI runners can be slow).
8460        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
8461        loop {
8462            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
8463                .await
8464                .is_ok()
8465            {
8466                break;
8467            }
8468            if std::time::Instant::now() >= deadline {
8469                panic!("HTTP server did not start within 5s on port {port}");
8470            }
8471            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8472        }
8473
8474        let client = reqwest::Client::new();
8475        let resp = client
8476            .get(format!("http://127.0.0.1:{port}/not-there"))
8477            .send()
8478            .await
8479            .unwrap();
8480        assert_eq!(resp.status().as_u16(), 404);
8481
8482        token.cancel();
8483    }
8484
8485    #[test]
8486    fn test_http_consumer_declares_concurrent() {
8487        use camel_component_api::ConcurrencyModel;
8488
8489        let config = HttpServerConfig {
8490            scheme: "http".to_string(),
8491            host: "127.0.0.1".to_string(),
8492            port: 19999,
8493            path: "/test".to_string(),
8494            max_request_body: 2 * 1024 * 1024,
8495            max_response_body: 10 * 1024 * 1024,
8496            max_inflight_requests: 1024,
8497            method: None,
8498            tls_config: None,
8499        };
8500        let consumer = HttpConsumer::new(config, test_rt());
8501        assert_eq!(
8502            consumer.concurrency_model(),
8503            ConcurrencyModel::Concurrent { max: None }
8504        );
8505    }
8506
8507    #[test]
8508    fn server_config_parses_tls_cert_and_key() {
8509        let cfg = HttpServerConfig::from_uri(
8510            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
8511        )
8512        .unwrap();
8513        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
8514        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
8515    }
8516
8517    #[test]
8518    fn server_config_no_tls_when_params_absent() {
8519        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
8520        assert!(cfg.tls_config.is_none());
8521    }
8522
8523    // -----------------------------------------------------------------------
8524    // HttpReplyBody streaming tests
8525    // -----------------------------------------------------------------------
8526
8527    #[tokio::test]
8528    async fn test_http_reply_body_stream_variant_exists() {
8529        use bytes::Bytes;
8530        use camel_component_api::CamelError;
8531        use futures::stream;
8532
8533        let chunks: Vec<Result<Bytes, CamelError>> =
8534            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
8535        let stream = Box::pin(stream::iter(chunks));
8536        let reply_body = HttpReplyBody::Stream(stream);
8537        // Si compila y el match funciona, el test pasa
8538        match reply_body {
8539            HttpReplyBody::Stream(_) => {}
8540            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
8541        }
8542    }
8543
8544    // -----------------------------------------------------------------------
8545    // OpenTelemetry propagation tests (only compiled with "otel" feature)
8546    // -----------------------------------------------------------------------
8547
8548    #[cfg(feature = "otel")]
8549    mod otel_tests {
8550        use super::*;
8551        use camel_component_api::Message;
8552        use tower::ServiceExt;
8553
8554        #[tokio::test]
8555        async fn test_producer_injects_traceparent_header() {
8556            let (url, _handle) = start_test_server_with_header_capture().await;
8557            let ctx = test_producer_ctx();
8558
8559            let component = HttpComponent::new();
8560            let endpoint_ctx = NoOpComponentContext;
8561            let endpoint = component
8562                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8563                .unwrap();
8564            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8565
8566            // Create exchange with an OTel context by extracting from a traceparent header
8567            let mut exchange = Exchange::new(Message::default());
8568            let mut headers = std::collections::HashMap::new();
8569            headers.insert(
8570                "traceparent".to_string(),
8571                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
8572            );
8573            camel_otel::extract_into_exchange(&mut exchange, &headers);
8574
8575            let result = producer.oneshot(exchange).await.unwrap();
8576
8577            // Verify request succeeded
8578            let status = result
8579                .input
8580                .header("CamelHttpResponseCode")
8581                .and_then(|v| v.as_u64())
8582                .unwrap();
8583            assert_eq!(status, 200);
8584
8585            // The test server echoes back the received traceparent header
8586            let traceparent = result.input.header("X-Received-Traceparent");
8587            assert!(
8588                traceparent.is_some(),
8589                "traceparent header should have been sent"
8590            );
8591
8592            let traceparent_str = traceparent.unwrap().as_str().unwrap();
8593            // Verify format: version-traceid-spanid-flags
8594            let parts: Vec<&str> = traceparent_str.split('-').collect();
8595            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8596            assert_eq!(parts[0], "00", "version should be 00");
8597            assert_eq!(
8598                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8599                "trace-id should match"
8600            );
8601            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
8602            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
8603        }
8604
8605        #[tokio::test]
8606        async fn test_consumer_extracts_traceparent_header() {
8607            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8608
8609            // Get an OS-assigned free port
8610            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8611            let port = listener.local_addr().unwrap().port();
8612            drop(listener);
8613
8614            let component = HttpComponent::new();
8615            let endpoint_ctx = NoOpComponentContext;
8616            let endpoint = component
8617                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8618                .unwrap();
8619            let mut consumer = endpoint.create_consumer(rt()).unwrap();
8620
8621            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8622            let token = tokio_util::sync::CancellationToken::new();
8623            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8624
8625            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8626            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8627
8628            // Send request with traceparent header
8629            let client = reqwest::Client::new();
8630            let send_fut = client
8631                .post(format!("http://127.0.0.1:{port}/trace"))
8632                .header(
8633                    "traceparent",
8634                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8635                )
8636                .body("test")
8637                .send();
8638
8639            let (http_result, _) = tokio::join!(send_fut, async {
8640                if let Some(envelope) = rx.recv().await {
8641                    // Verify the exchange has a valid OTel context by re-injecting it
8642                    // and checking the traceparent matches
8643                    let mut injected_headers = std::collections::HashMap::new();
8644                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8645
8646                    assert!(
8647                        injected_headers.contains_key("traceparent"),
8648                        "Exchange should have traceparent after extraction"
8649                    );
8650
8651                    let traceparent = injected_headers.get("traceparent").unwrap();
8652                    let parts: Vec<&str> = traceparent.split('-').collect();
8653                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8654                    assert_eq!(
8655                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8656                        "Trace ID should match the original traceparent header"
8657                    );
8658
8659                    if let Some(reply_tx) = envelope.reply_tx {
8660                        let _ = reply_tx.send(Ok(envelope.exchange));
8661                    }
8662                }
8663            });
8664
8665            let resp = http_result.unwrap();
8666            assert_eq!(resp.status().as_u16(), 200);
8667
8668            token.cancel();
8669        }
8670
8671        #[tokio::test]
8672        async fn test_consumer_extracts_mixed_case_traceparent_header() {
8673            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8674
8675            // Get an OS-assigned free port
8676            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8677            let port = listener.local_addr().unwrap().port();
8678            drop(listener);
8679
8680            let component = HttpComponent::new();
8681            let endpoint_ctx = NoOpComponentContext;
8682            let endpoint = component
8683                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8684                .unwrap();
8685            let mut consumer = endpoint.create_consumer(rt()).unwrap();
8686
8687            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8688            let token = tokio_util::sync::CancellationToken::new();
8689            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8690
8691            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8692            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8693
8694            // Send request with MIXED-CASE TraceParent header (not lowercase)
8695            let client = reqwest::Client::new();
8696            let send_fut = client
8697                .post(format!("http://127.0.0.1:{port}/trace"))
8698                .header(
8699                    "TraceParent",
8700                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8701                )
8702                .body("test")
8703                .send();
8704
8705            let (http_result, _) = tokio::join!(send_fut, async {
8706                if let Some(envelope) = rx.recv().await {
8707                    // Verify the exchange has a valid OTel context by re-injecting it
8708                    // and checking the traceparent matches
8709                    let mut injected_headers = HashMap::new();
8710                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8711
8712                    assert!(
8713                        injected_headers.contains_key("traceparent"),
8714                        "Exchange should have traceparent after extraction from mixed-case header"
8715                    );
8716
8717                    let traceparent = injected_headers.get("traceparent").unwrap();
8718                    let parts: Vec<&str> = traceparent.split('-').collect();
8719                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8720                    assert_eq!(
8721                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8722                        "Trace ID should match the original mixed-case TraceParent header"
8723                    );
8724
8725                    if let Some(reply_tx) = envelope.reply_tx {
8726                        let _ = reply_tx.send(Ok(envelope.exchange));
8727                    }
8728                }
8729            });
8730
8731            let resp = http_result.unwrap();
8732            assert_eq!(resp.status().as_u16(), 200);
8733
8734            token.cancel();
8735        }
8736
8737        #[tokio::test]
8738        async fn test_producer_no_trace_context_no_crash() {
8739            let (url, _handle) = start_test_server().await;
8740            let ctx = test_producer_ctx();
8741
8742            let component = HttpComponent::new();
8743            let endpoint_ctx = NoOpComponentContext;
8744            let endpoint = component
8745                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8746                .unwrap();
8747            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8748
8749            // Create exchange with default (empty) otel_context - no trace context
8750            let exchange = Exchange::new(Message::default());
8751
8752            // Should succeed without panic
8753            let result = producer.oneshot(exchange).await.unwrap();
8754
8755            // Verify request succeeded
8756            let status = result
8757                .input
8758                .header("CamelHttpResponseCode")
8759                .and_then(|v| v.as_u64())
8760                .unwrap();
8761            assert_eq!(status, 200);
8762        }
8763
8764        /// Test server that captures and echoes back the traceparent header
8765        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
8766            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8767            let addr = listener.local_addr().unwrap();
8768            let url = format!("http://127.0.0.1:{}", addr.port());
8769
8770            let handle = tokio::spawn(async move {
8771                loop {
8772                    if let Ok((mut stream, _)) = listener.accept().await {
8773                        tokio::spawn(async move {
8774                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
8775                            let mut buf = vec![0u8; 8192];
8776                            let n = stream.read(&mut buf).await.unwrap_or(0);
8777                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
8778
8779                            // Extract traceparent header from request
8780                            let traceparent = request
8781                                .lines()
8782                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
8783                                .map(|line| {
8784                                    line.split(':')
8785                                        .nth(1)
8786                                        .map(|s| s.trim().to_string())
8787                                        .unwrap_or_default()
8788                                })
8789                                .unwrap_or_default();
8790
8791                            let body =
8792                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
8793                            let response = format!(
8794                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
8795                                body.len(),
8796                                traceparent,
8797                                body
8798                            );
8799                            let _ = stream.write_all(response.as_bytes()).await;
8800                        });
8801                    }
8802                }
8803            });
8804
8805            (url, handle)
8806        }
8807    }
8808
8809    // -----------------------------------------------------------------------
8810    // Response streaming tests (Eje A - Task 2)
8811    // -----------------------------------------------------------------------
8812
8813    // -----------------------------------------------------------------------
8814    // Request streaming tests (Eje B - Task 3)
8815    // -----------------------------------------------------------------------
8816
8817    #[tokio::test]
8818    async fn test_request_body_arrives_as_stream() {
8819        use camel_component_api::Body;
8820        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8821
8822        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8823        let port = listener.local_addr().unwrap().port();
8824        drop(listener);
8825
8826        let component = HttpComponent::new();
8827        let endpoint_ctx = NoOpComponentContext;
8828        let endpoint = component
8829            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
8830            .unwrap();
8831        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8832
8833        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8834        let token = tokio_util::sync::CancellationToken::new();
8835        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8836
8837        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8838        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8839
8840        let client = reqwest::Client::new();
8841        let send_fut = client
8842            .post(format!("http://127.0.0.1:{port}/upload"))
8843            .body("hello streaming world")
8844            .send();
8845
8846        let (http_result, _) = tokio::join!(send_fut, async {
8847            if let Some(mut envelope) = rx.recv().await {
8848                // Body must be Body::Stream, not Body::Text or Body::Bytes
8849                assert!(
8850                    matches!(envelope.exchange.input.body, Body::Stream(_)),
8851                    "expected Body::Stream, got discriminant {:?}",
8852                    std::mem::discriminant(&envelope.exchange.input.body)
8853                );
8854                // Materialize to verify content
8855                let bytes = envelope
8856                    .exchange
8857                    .input
8858                    .body
8859                    .into_bytes(1024 * 1024)
8860                    .await
8861                    .unwrap();
8862                assert_eq!(&bytes[..], b"hello streaming world");
8863
8864                envelope.exchange.input.body = camel_component_api::Body::Empty;
8865                if let Some(reply_tx) = envelope.reply_tx {
8866                    let _ = reply_tx.send(Ok(envelope.exchange));
8867                }
8868            }
8869        });
8870
8871        let resp = http_result.unwrap();
8872        assert_eq!(resp.status().as_u16(), 200);
8873
8874        token.cancel();
8875    }
8876
8877    // -----------------------------------------------------------------------
8878    // Response streaming tests (Eje A - Task 2)
8879    // -----------------------------------------------------------------------
8880
8881    #[tokio::test]
8882    async fn test_streaming_response_chunked() {
8883        use bytes::Bytes;
8884        use camel_component_api::Body;
8885        use camel_component_api::CamelError;
8886        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8887        use camel_component_api::{StreamBody, StreamMetadata};
8888        use futures::stream;
8889        use std::sync::Arc;
8890        use tokio::sync::Mutex;
8891
8892        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8893        let port = listener.local_addr().unwrap().port();
8894        drop(listener);
8895
8896        let component = HttpComponent::new();
8897        let endpoint_ctx = NoOpComponentContext;
8898        let endpoint = component
8899            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
8900            .unwrap();
8901        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8902
8903        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8904        let token = tokio_util::sync::CancellationToken::new();
8905        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8906
8907        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8908        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8909
8910        let client = reqwest::Client::new();
8911        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
8912
8913        let (http_result, _) = tokio::join!(send_fut, async {
8914            if let Some(mut envelope) = rx.recv().await {
8915                // Respond with Body::Stream
8916                let chunks: Vec<Result<Bytes, CamelError>> =
8917                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
8918                let stream = Box::pin(stream::iter(chunks));
8919                envelope.exchange.input.body = Body::Stream(StreamBody {
8920                    stream: Arc::new(Mutex::new(Some(stream))),
8921                    metadata: StreamMetadata::default(),
8922                });
8923                if let Some(reply_tx) = envelope.reply_tx {
8924                    let _ = reply_tx.send(Ok(envelope.exchange));
8925                }
8926            }
8927        });
8928
8929        let resp = http_result.unwrap();
8930        assert_eq!(resp.status().as_u16(), 200);
8931        let body = resp.text().await.unwrap();
8932        assert_eq!(body, "chunk1chunk2");
8933
8934        token.cancel();
8935    }
8936
8937    // -----------------------------------------------------------------------
8938    // 413 Content-Length limit test (Task 4)
8939    // -----------------------------------------------------------------------
8940
8941    #[tokio::test]
8942    async fn test_413_when_content_length_exceeds_limit() {
8943        use camel_component_api::ConsumerContext;
8944
8945        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8946        let port = listener.local_addr().unwrap().port();
8947        drop(listener);
8948
8949        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
8950        let component = HttpComponent::new();
8951        let endpoint_ctx = NoOpComponentContext;
8952        let endpoint = component
8953            .create_endpoint(
8954                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
8955                &endpoint_ctx,
8956            )
8957            .unwrap();
8958        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8959
8960        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8961        let token = tokio_util::sync::CancellationToken::new();
8962        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8963
8964        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8965        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8966
8967        let client = reqwest::Client::new();
8968        let resp = client
8969            .post(format!("http://127.0.0.1:{port}/upload"))
8970            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
8971            .body("x".repeat(1000))
8972            .send()
8973            .await
8974            .unwrap();
8975
8976        assert_eq!(resp.status().as_u16(), 413);
8977
8978        token.cancel();
8979    }
8980
8981    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
8982    /// The spec says: "If there is no Content-Length, the limit does not apply at the
8983    /// consumer level — the route is responsible."
8984    #[tokio::test]
8985    async fn test_chunked_upload_without_content_length_bypasses_limit() {
8986        use bytes::Bytes;
8987        use camel_component_api::Body;
8988        use camel_component_api::ConsumerContext;
8989        use futures::stream;
8990
8991        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8992        let port = listener.local_addr().unwrap().port();
8993        drop(listener);
8994
8995        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
8996        let component = HttpComponent::new();
8997        let endpoint_ctx = NoOpComponentContext;
8998        let endpoint = component
8999            .create_endpoint(
9000                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
9001                &endpoint_ctx,
9002            )
9003            .unwrap();
9004        let mut consumer = endpoint.create_consumer(rt()).unwrap();
9005
9006        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9007        let token = tokio_util::sync::CancellationToken::new();
9008        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9009
9010        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9011        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9012
9013        let client = reqwest::Client::new();
9014
9015        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
9016        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
9017        // but since there's no Content-Length the 413 check must NOT fire.
9018        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
9019            Ok(Bytes::from("y".repeat(50))),
9020            Ok(Bytes::from("y".repeat(50))),
9021        ];
9022        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
9023        let send_fut = client
9024            .post(format!("http://127.0.0.1:{port}/upload"))
9025            .body(stream_body)
9026            .send();
9027
9028        let consumer_fut = async {
9029            // Use timeout to avoid deadlock if the handler rejects before enqueueing
9030            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
9031                Ok(Some(mut envelope)) => {
9032                    assert!(
9033                        matches!(envelope.exchange.input.body, Body::Stream(_)),
9034                        "expected Body::Stream"
9035                    );
9036                    envelope.exchange.input.body = camel_component_api::Body::Empty;
9037                    if let Some(reply_tx) = envelope.reply_tx {
9038                        let _ = reply_tx.send(Ok(envelope.exchange));
9039                    }
9040                }
9041                Ok(None) => panic!("consumer channel closed unexpectedly"),
9042                Err(_) => {
9043                    // Timeout: the request was rejected before reaching the consumer.
9044                    // The HTTP response will carry the real status code (we check below).
9045                }
9046            }
9047        };
9048
9049        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
9050
9051        let resp = http_result.unwrap();
9052        // Audit 2026-08-31 (F2-1): the request is no longer rejected at the door
9053        // (no Content-Length to pre-check), but the byte cap now travels with the
9054        // stream: ANY materialization past maxRequestBody fails closed. This test
9055        // does not consume the body, so the request still completes with 200 —
9056        // enforcement happens at consumption time (see
9057        // test_http_consumer_chunked_body_is_capped).
9058        assert_ne!(
9059            resp.status().as_u16(),
9060            413,
9061            "chunked upload has no Content-Length to pre-check"
9062        );
9063        assert_eq!(resp.status().as_u16(), 200);
9064
9065        token.cancel();
9066    }
9067
9068    #[test]
9069    fn test_is_private_ip_ranges() {
9070        use camel_api::is_ssrf_blocked_ip;
9071        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
9072        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
9073        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
9074        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
9075        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
9076        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
9077
9078        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
9079        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
9080        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
9081        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
9082        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
9083        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
9084        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
9085        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
9086
9087        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
9088        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
9089        assert!(!is_ssrf_blocked_ip(
9090            &"2001:4860:4860::8888".parse().unwrap()
9091        )); // allow-unwrap
9092    }
9093
9094    #[test]
9095    fn test_title_case_header() {
9096        assert_eq!(title_case_header("content-type"), "Content-Type");
9097        assert_eq!(title_case_header("authorization"), "Authorization");
9098        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
9099        assert_eq!(title_case_header("host"), "Host");
9100        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
9101        assert_eq!(title_case_header("single"), "Single");
9102        assert_eq!(title_case_header(""), "");
9103    }
9104
9105    #[test]
9106    fn test_resolve_url_combines_path_and_query_sources() {
9107        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
9108        let mut exchange = Exchange::new(Message::default());
9109        exchange.input.set_header(
9110            "CamelHttpPath",
9111            serde_json::Value::String("next".to_string()),
9112        );
9113        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9114        assert!(url.starts_with("http://example.com/base/next?"));
9115        assert!(url.contains("foo=bar"));
9116
9117        exchange.input.set_header(
9118            "CamelHttpUri",
9119            serde_json::Value::String("http://other.test/root".to_string()),
9120        );
9121        exchange.input.set_header(
9122            "CamelHttpQuery",
9123            serde_json::Value::String("a=1&b=2".to_string()),
9124        );
9125
9126        let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9127        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
9128    }
9129
9130    fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
9131        let mut exchange = Exchange::new(Message::default());
9132        exchange
9133            .input
9134            .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
9135        exchange.input.set_header(
9136            "CamelHttpQuery",
9137            serde_json::Value::String(query.to_string()),
9138        );
9139        exchange
9140    }
9141
9142    #[test]
9143    fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
9144        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9145        cfg.bridge_endpoint = true;
9146        cfg.query_params
9147            .push(("token".to_string(), "secret".to_string()));
9148        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9149        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9150        // Verbatim assembly: the old round-trip normalized the empty base
9151        // path to `/` (`http://x/?token=secret`); authored bytes end-to-end
9152        // no longer insert it.
9153        assert_eq!(url, "http://x?token=secret");
9154        assert!(!url.contains("/foo"));
9155        assert!(!url.contains("dropme"));
9156    }
9157
9158    #[test]
9159    fn resolve_url_bridge_endpoint_false_merges_path() {
9160        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9161        cfg.bridge_endpoint = false;
9162        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9163        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9164        assert!(url.contains("/foo"), "url should contain /foo: {url}");
9165        assert!(
9166            url.contains("dropme=1"),
9167            "url should contain dropme=1: {url}"
9168        );
9169    }
9170
9171    #[test]
9172    fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
9173        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9174        cfg.bridge_endpoint = true;
9175        let mut exchange = Exchange::new(Message::default());
9176        exchange.input.set_header(
9177            "CamelHttpPath",
9178            serde_json::Value::String("/foo".to_string()),
9179        );
9180        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9181        assert_eq!(url, "http://x");
9182        assert!(!url.contains("/foo"));
9183    }
9184
9185    #[test]
9186    fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
9187        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9188        cfg.bridge_endpoint = true;
9189        // query_params stays empty ([])
9190        let mut exchange = Exchange::new(Message::default());
9191        exchange.input.set_header(
9192            "CamelHttpUri",
9193            serde_json::Value::String("http://dest/explicit".to_string()),
9194        );
9195        exchange.input.set_header(
9196            "CamelHttpPath",
9197            serde_json::Value::String("/foo".to_string()),
9198        );
9199        exchange.input.set_header(
9200            "CamelHttpQuery",
9201            serde_json::Value::String("x=1".to_string()),
9202        );
9203        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9204        // Under bridgeEndpoint=true ALL exchange URL headers (CamelHttpUri,
9205        // CamelHttpPath, CamelHttpQuery) are ignored; the endpoint base URL
9206        // wins verbatim.
9207        assert_eq!(url, "http://x");
9208    }
9209
9210    #[test]
9211    fn bridge_programmatic_params_use_percent20() {
9212        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9213        cfg.bridge_endpoint = true;
9214        cfg.query_params = vec![("b".to_string(), "x y".to_string())];
9215        let exchange = Exchange::new(Message::default());
9216
9217        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9218
9219        // `%20 never +` is global for programmatic values — the bridge arm
9220        // uses the same encoder as the non-bridge path. Bridging
9221        // semantics (what gets bridged, precedence) are unchanged.
9222        assert_eq!(url, "http://x?b=x%20y");
9223        assert!(!url.contains('+'));
9224    }
9225
9226    #[test]
9227    fn bridge_arm_carries_authored_raw_query() {
9228        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9229        // bridgeEndpoint is consumed as an endpoint option; a=1 is the
9230        // authored leftover riding raw_query.
9231        let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
9232
9233        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9234
9235        // Authored leftovers ride under bridging (Apache Camel semantics):
9236        // query is a=1 in authored bytes; exchange path/query stay ignored.
9237        assert_eq!(url, "http://h/p?a=1");
9238        assert!(!url.contains("dropme"), "exchange query leaked: {url}");
9239        assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
9240    }
9241
9242    // -----------------------------------------------------------------------
9243    // Bridge arm verbatim assembly (Papal Direction A): the bridged base is
9244    // never round-tripped through `url::Url` normalization — authored bytes
9245    // end-to-end, identical assembly to every other resolve_url arm.
9246    // -----------------------------------------------------------------------
9247
9248    #[test]
9249    fn resolve_url_bridge_preserves_dot_segments() {
9250        let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
9251        cfg.bridge_endpoint = true;
9252        cfg.query_params.push(("k".to_string(), "1".to_string()));
9253        let exchange = Exchange::new(Message::default());
9254
9255        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9256
9257        // Dot segments are authored bytes; the old round-trip collapsed
9258        // them (`/a/../b` → `/b`). Verbatim keeps them.
9259        assert_eq!(url, "http://h/a/../b?k=1");
9260    }
9261
9262    #[test]
9263    fn resolve_url_bridge_preserves_default_port() {
9264        let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
9265        cfg.bridge_endpoint = true;
9266        cfg.query_params.push(("k".to_string(), "1".to_string()));
9267        let exchange = Exchange::new(Message::default());
9268
9269        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9270
9271        // The old round-trip stripped the default port `:80`. Verbatim
9272        // keeps it.
9273        assert_eq!(url, "http://h:80/p?k=1");
9274    }
9275
9276    #[test]
9277    fn resolve_url_bridge_preserves_scheme_and_host_case() {
9278        let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
9279        cfg.bridge_endpoint = true;
9280        cfg.query_params.push(("k".to_string(), "1".to_string()));
9281        // `from_uri`'s scheme validation is case-sensitive, so the scheme
9282        // case is applied on the stored base directly — the resolve path
9283        // must carry whatever bytes the operator authored.
9284        cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
9285        let exchange = Exchange::new(Message::default());
9286
9287        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9288
9289        // The old round-trip lowercased scheme and host. Verbatim keeps
9290        // both authored.
9291        assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
9292    }
9293
9294    #[test]
9295    fn resolve_url_bridge_no_query_emits_base_verbatim() {
9296        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9297        cfg.bridge_endpoint = true;
9298        let exchange = Exchange::new(Message::default());
9299
9300        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9301
9302        // No resolved query: exactly the authored base — no synthetic `/`,
9303        // no dangling `?`.
9304        assert_eq!(url, "http://h/p");
9305    }
9306
9307    #[test]
9308    fn resolve_url_bridge_and_non_bridge_byte_identical() {
9309        // (a) Bridged arm: the effective query comes from programmatic
9310        // query_params.
9311        let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9312        bridged.bridge_endpoint = true;
9313        bridged
9314            .query_params
9315            .push(("k".to_string(), "1".to_string()));
9316        let bridge_url =
9317            HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
9318
9319        // (b) Non-bridge CamelHttpQuery composition path: same effective
9320        // query riding the exchange header.
9321        let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9322        let mut exchange = Exchange::new(Message::default());
9323        exchange.input.set_header(
9324            "CamelHttpQuery",
9325            serde_json::Value::String("k=1".to_string()),
9326        );
9327        let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
9328
9329        assert_eq!(bridge_url, plain_url);
9330        assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
9331    }
9332
9333    #[test]
9334    fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
9335        let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
9336        cfg.bridge_endpoint = true;
9337        cfg.query_params.push(("k".to_string(), "1".to_string()));
9338        let exchange = Exchange::new(Message::default());
9339
9340        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9341
9342        assert_eq!(url, "http://[::1]:8080/p?k=1");
9343    }
9344
9345    #[test]
9346    fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
9347        let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
9348        let exchange = Exchange::new(Message::default());
9349
9350        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9351
9352        // Authored query on an empty base path: the old round-trip
9353        // inserted a synthetic `/` (`http://h/?x=1`); verbatim does not.
9354        assert_eq!(url, "http://h?x=1");
9355    }
9356
9357    // -----------------------------------------------------------------------
9358    // Raw-preserving outbound query serialization (http-query-wire-fidelity)
9359    // -----------------------------------------------------------------------
9360
9361    #[test]
9362    fn resolve_url_preserves_authored_query_order_and_bytes() {
9363        let config =
9364            HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
9365        let exchange = Exchange::new(Message::default());
9366
9367        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9368
9369        // Authored order, authored separators, no %2C/%3A re-encoding,
9370        // consumed option (connectTimeout) removed.
9371        assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
9372    }
9373
9374    #[test]
9375    fn resolve_url_consumes_encoded_option_key() {
9376        let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
9377        let exchange = Exchange::new(Message::default());
9378
9379        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9380
9381        // The raw filter matches the decoded key, not the encoded bytes.
9382        assert_eq!(url, "http://h/p?a=1");
9383    }
9384
9385    #[test]
9386    fn resolve_url_all_options_consumed_drops_query() {
9387        let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
9388        let exchange = Exchange::new(Message::default());
9389
9390        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9391
9392        // A non-empty query whose every pair was consumed drops the query
9393        // component entirely — no dangling `?`.
9394        assert_eq!(url, "http://h/p");
9395        assert!(!url.contains('?'));
9396    }
9397
9398    #[test]
9399    fn resolve_url_preserves_empty_query_marker() {
9400        let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
9401        let exchange = Exchange::new(Message::default());
9402
9403        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9404
9405        // A bare `?` marker is preserved distinctly, never conflated with
9406        // an all-consumed query.
9407        assert_eq!(url, "http://h/p?");
9408    }
9409
9410    #[test]
9411    fn resolve_url_raw_wrapper_not_re_encoded() {
9412        let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
9413        let exchange = Exchange::new(Message::default());
9414
9415        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9416
9417        // RAW(...) wrapper bytes survive exactly as authored (rc-g4isv).
9418        assert_eq!(url, "http://h/p?token=RAW(abc)");
9419        assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
9420    }
9421
9422    #[test]
9423    fn resolve_url_camel_http_query_composes_verbatim_span() {
9424        let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
9425        let mut exchange = Exchange::new(Message::default());
9426        exchange.input.set_header(
9427            "CamelHttpQuery",
9428            serde_json::Value::String("userFilter=a%2Cb".to_string()),
9429        );
9430
9431        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9432
9433        // Policy change (ADR-0071): the header no longer replaces the
9434        // endpoint query — it composes, the endpoint winning collisions.
9435        // The header span bytes still ride verbatim: `a%2Cb` is carried
9436        // as-authored, never re-encoded (no %252C).
9437        assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
9438        assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
9439    }
9440
9441    // -----------------------------------------------------------------------
9442    // Outbound query composition (http-contract-surface, ADR-0071)
9443    // -----------------------------------------------------------------------
9444
9445    #[test]
9446    fn header_composes_with_endpoint_query() {
9447        let config =
9448            HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
9449        let mut exchange = Exchange::new(Message::default());
9450        exchange.input.set_header(
9451            "CamelHttpQuery",
9452            serde_json::Value::String("lang=es&page=2".to_string()),
9453        );
9454
9455        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9456
9457        // Higher precedence (endpoint) wins collisions: `lang` stays `en`;
9458        // the header appends only its absent keys.
9459        assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
9460    }
9461
9462    #[test]
9463    fn header_alone_still_rides() {
9464        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9465        let mut exchange = Exchange::new(Message::default());
9466        exchange.input.set_header(
9467            "CamelHttpQuery",
9468            serde_json::Value::String("page=2".to_string()),
9469        );
9470
9471        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9472
9473        // No endpoint query: the header pairs are the whole query.
9474        assert_eq!(url, "http://upstream/api?page=2");
9475    }
9476
9477    #[test]
9478    fn empty_reflected_query_leaves_endpoint_query_intact() {
9479        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9480        let mut exchange = Exchange::new(Message::default());
9481        // The consumer installs an empty CamelHttpQuery on requests that
9482        // arrived without a query string.
9483        exchange
9484            .input
9485            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
9486
9487        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9488
9489        // No second `?` marker, no dropped endpoint pair.
9490        assert_eq!(url, "http://upstream/api?apiKey=secret");
9491        assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
9492    }
9493
9494    #[test]
9495    fn forbidden_byte_in_header_query_errors() {
9496        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9497        let mut exchange = Exchange::new(Message::default());
9498        exchange.input.set_header(
9499            "CamelHttpQuery",
9500            serde_json::Value::String("q=ab<cd".to_string()),
9501        );
9502
9503        let err = HttpProducer::resolve_url(&exchange, &config)
9504            .unwrap_err()
9505            .to_string();
9506
9507        // Fail loud naming the forbidden byte (`<` = 0x3C); a resolve
9508        // error means no URL is emitted, never a re-encoded one.
9509        assert!(err.contains("0x3C"), "error must name the byte: {err}");
9510    }
9511
9512    #[test]
9513    fn override_uri_with_query_plus_header_query() {
9514        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9515        let mut exchange = Exchange::new(Message::default());
9516        exchange.input.set_header(
9517            "CamelHttpUri",
9518            serde_json::Value::String("http://host/api?a=1".to_string()),
9519        );
9520        exchange.input.set_header(
9521            "CamelHttpQuery",
9522            serde_json::Value::String("a=2&b=3".to_string()),
9523        );
9524
9525        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9526
9527        // Pair-level merge with a single `?`: the override's `a=1` wins
9528        // the collision, the header appends `b=3` — no `?a=1?a=2` concat.
9529        assert_eq!(url, "http://host/api?a=1&b=3");
9530    }
9531
9532    #[test]
9533    fn path_applies_before_query_composition() {
9534        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9535        let mut exchange = Exchange::new(Message::default());
9536        exchange.input.set_header(
9537            "CamelHttpUri",
9538            serde_json::Value::String("http://host/api?a=1".to_string()),
9539        );
9540        exchange.input.set_header(
9541            "CamelHttpPath",
9542            serde_json::Value::String("/extra".to_string()),
9543        );
9544        exchange.input.set_header(
9545            "CamelHttpQuery",
9546            serde_json::Value::String("b=2".to_string()),
9547        );
9548
9549        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9550
9551        // CamelHttpPath applies to the override base without its query,
9552        // then the query composes.
9553        assert_eq!(url, "http://host/api/extra?a=1&b=2");
9554    }
9555
9556    #[test]
9557    fn plain_proxy_reflection_composes() {
9558        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9559        // Headers as the consumer installs them from the wire.
9560        let exchange = exchange_with_path_and_query("/in/extra", "page=2");
9561
9562        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9563
9564        // Reflection rides by default and composes: the operator pair is
9565        // not replaced (rc-k3pir parity).
9566        assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
9567    }
9568
9569    #[test]
9570    fn bridge_endpoint_ignores_url_headers() {
9571        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9572        let mut exchange = Exchange::new(Message::default());
9573        exchange.input.set_header(
9574            "CamelHttpUri",
9575            serde_json::Value::String("http://evil.test/x".to_string()),
9576        );
9577        exchange.input.set_header(
9578            "CamelHttpPath",
9579            serde_json::Value::String("/foo".to_string()),
9580        );
9581        exchange.input.set_header(
9582            "CamelHttpQuery",
9583            serde_json::Value::String("z=9".to_string()),
9584        );
9585
9586        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9587
9588        // All three URL headers ignored; the endpoint base plus its own
9589        // (consumed-option-filtered) query is sent, exactly as before.
9590        assert_eq!(url, "http://h/p?a=1");
9591        assert!(!url.contains("evil"), "override leaked: {url}");
9592        assert!(!url.contains("z=9"), "header query leaked: {url}");
9593        assert!(!url.contains("/foo"), "header path leaked: {url}");
9594    }
9595
9596    #[test]
9597    fn resolve_url_programmatic_params_use_percent20_deterministic() {
9598        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9599        config.query_params = vec![
9600            ("b".to_string(), "x y".to_string()),
9601            ("a".to_string(), "1".to_string()),
9602        ];
9603        let exchange = Exchange::new(Message::default());
9604
9605        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9606
9607        // Declaration order (not lexical), minimal RFC-3986 encoding,
9608        // `%20` — never `+` — for spaces.
9609        assert_eq!(url, "http://h/p?b=x%20y&a=1");
9610        assert!(!url.contains('+'));
9611    }
9612
9613    #[test]
9614    fn resolve_url_authored_and_programmatic_merge() {
9615        let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
9616        config.query_params = vec![
9617            ("b".to_string(), "2".to_string()),
9618            ("a".to_string(), "9".to_string()),
9619        ];
9620        let exchange = Exchange::new(Message::default());
9621
9622        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9623
9624        // Programmatic `b` appended (absent from raw); programmatic `a=9`
9625        // ignored (authored key wins); no duplication.
9626        assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
9627    }
9628
9629    #[test]
9630    fn from_uri_no_longer_fills_query_params_from_uri() {
9631        let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
9632
9633        // Authored pairs live in raw_query ONLY (provenance pin).
9634        assert!(
9635            config.query_params.is_empty(),
9636            "query_params is programmatic-only: {:?}",
9637            config.query_params
9638        );
9639        assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
9640    }
9641
9642    #[test]
9643    fn resolve_url_forbidden_raw_byte_errors() {
9644        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9645        config.raw_query = Some("a=x y".to_string());
9646        let exchange = Exchange::new(Message::default());
9647
9648        let err = HttpProducer::resolve_url(&exchange, &config)
9649            .expect_err("literal space in raw query must error");
9650
9651        // The error names the forbidden byte; no output string is produced.
9652        assert!(
9653            err.to_string().contains("0x20"),
9654            "error must name the forbidden byte: {err}"
9655        );
9656    }
9657
9658    /// rc-m4xk1: the override URI's own query is span-validated at resolve
9659    /// time — a forbidden byte in the override arm errors naming the byte,
9660    /// instead of riding verbatim to a reqwest send error.
9661    #[test]
9662    fn resolve_url_override_query_forbidden_byte_errors() {
9663        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9664        let mut exchange = Exchange::new(Message::default());
9665        exchange.input.set_header(
9666            "CamelHttpUri",
9667            serde_json::Value::String("http://h2/p?a=x y".to_string()),
9668        );
9669
9670        let err = HttpProducer::resolve_url(&exchange, &config)
9671            .expect_err("literal space in the override URI's query must error");
9672
9673        assert!(
9674            err.to_string().contains("0x20"),
9675            "error must name the forbidden byte from the override query: {err}"
9676        );
9677    }
9678
9679    /// rc-m4xk1 pin: decoded-key collision — a header pair whose key decodes
9680    /// to a key already present in the higher-precedence query (here
9681    /// `%61=2`, decoding to `a`) is dropped by the shared decoded-key
9682    /// matching; the higher-precedence authored span rides verbatim.
9683    #[test]
9684    fn merge_header_query_decoded_key_collision_drops_header_pair() {
9685        let merged = merge_header_query(Some("a=1"), "%61=2")
9686            .expect("decoded-key collision must not be a parse error");
9687        assert_eq!(
9688            merged.as_deref(),
9689            Some("a=1"),
9690            "the higher-precedence span wins and the colliding header pair is dropped"
9691        );
9692    }
9693
9694    /// rc-m4xk1 pin: duplicate keys within ONE header query are not
9695    /// deduplicated — both spans ride verbatim in authored order.
9696    #[test]
9697    fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
9698        let merged = merge_header_query(None, "k=1&k=2")
9699            .expect("duplicate header keys must not be a parse error");
9700        assert_eq!(
9701            merged.as_deref(),
9702            Some("k=1&k=2"),
9703            "intra-header duplicate keys ride verbatim"
9704        );
9705    }
9706
9707    /// rc-dhkeo: the Debug surface masks userinfo-style bytes in
9708    /// `base_url` and leaves a userinfo-free base untouched, byte-for-byte.
9709    /// rc-yvjp3 (ADR-0076 strictest-wins): `base_url` routes through the
9710    /// canonical `camel_api::redact::redact_url` — query and fragment bytes
9711    /// now drop behind their sentinels and later `//user:pass@` windows
9712    /// mask too, dimensions the former byte-preserving local variant kept.
9713    #[test]
9714    fn endpoint_config_debug_masks_base_url_userinfo() {
9715        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9716        config.base_url = "http://user:pass@h.example/p".to_string();
9717        let rendered = format!("{config:?}");
9718        assert!(
9719            rendered.contains("***@h.example"),
9720            "userinfo must render masked: {rendered}"
9721        );
9722        assert!(
9723            !rendered.contains("user:pass"),
9724            "no credentials in Debug output: {rendered}"
9725        );
9726
9727        let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9728        let rendered_plain = format!("{plain:?}");
9729        assert!(
9730            rendered_plain.contains("http://h.example/p"),
9731            "a base without userinfo renders unchanged: {rendered_plain}"
9732        );
9733    }
9734
9735    /// rc-yvjp3 convergence: an authored query and fragment on `base_url`
9736    /// render as sentinels, never as raw bytes (strictest-wins over the
9737    /// former byte-preserving variant), and the rendered value is
9738    /// byte-identical to the canonical helper.
9739    #[test]
9740    fn endpoint_config_debug_base_url_converges_on_canonical_redact() {
9741        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9742
9743        config.base_url = "http://h.example/p?token=secret#access_token=x".to_string();
9744        let rendered = format!("{config:?}");
9745        assert!(
9746            rendered.contains("base_url: \"http://h.example/p?[redacted]#[redacted]\""),
9747            "query and fragment must render as composed sentinels: {rendered}"
9748        );
9749        assert!(
9750            !rendered.contains("token=secret") && !rendered.contains("access_token"),
9751            "query/fragment credential bytes must not render: {rendered}"
9752        );
9753
9754        config.base_url = "http://h.example//u2:p2@evil/".to_string();
9755        let rendered = format!("{config:?}");
9756        assert!(
9757            rendered.contains("base_url: \"http://h.example//***@evil/\""),
9758            "later //window userinfo must mask (canonical window rule): {rendered}"
9759        );
9760        assert!(
9761            !rendered.contains("u2:p2"),
9762            "later-window credentials must not render: {rendered}"
9763        );
9764
9765        // Cross-surface identity: the Debug field is byte-identical to the
9766        // canonical helper output for the same input.
9767        config.base_url = "http://user:pass@h.example/p?token=x".to_string();
9768        let canonical = camel_api::redact::redact_url(&config.base_url);
9769        assert_eq!(canonical, "http://***@h.example/p?[redacted]");
9770        let rendered = format!("{config:?}");
9771        assert!(
9772            rendered.contains(&format!("base_url: \"{canonical}\"")),
9773            "Debug base_url must equal canonical redact_url output: {rendered}"
9774        );
9775    }
9776
9777    /// rc-nmupb: authored apostrophe (0x27) is RFC 3986 pchar-legal, but
9778    /// reqwest's WHATWG parser re-encodes it as `%27` in every http/https
9779    /// query — the raw byte can never ride the wire verbatim. Resolve
9780    /// rejects it naming the byte; the authored `%27` escape is the
9781    /// wire-faithful form and rides verbatim.
9782    #[test]
9783    fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
9784        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9785
9786        config.raw_query = Some("q=it's".to_string());
9787        let exchange = Exchange::new(Message::default());
9788        let err = HttpProducer::resolve_url(&exchange, &config)
9789            .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
9790        assert!(
9791            err.to_string().contains("0x27"),
9792            "error must name the apostrophe byte: {err}"
9793        );
9794
9795        config.raw_query = Some("q=it%27s".to_string());
9796        let url = HttpProducer::resolve_url(&exchange, &config)
9797            .expect("authored %27 escape is wire-legal");
9798        assert!(
9799            url.contains("q=it%27s"),
9800            "the authored escape must ride byte-for-byte: {url}"
9801        );
9802
9803        // The rest of reqwest's WHATWG special-query set shares the same
9804        // rationale and is rejected alongside (`"` and backtick are not
9805        // RFC 3986 query-legal bytes; `<`/`>` likewise).
9806        for &byte in b"\"`<>" {
9807            config.raw_query = Some(format!("k={}x", byte as char));
9808            let err = HttpProducer::resolve_url(&exchange, &config)
9809                .expect_err("WHATWG special-query byte must be rejected");
9810            assert!(
9811                err.to_string().contains(&format!("0x{byte:02X}")),
9812                "error must name byte 0x{byte:02X}: {err}"
9813            );
9814        }
9815    }
9816
9817    #[test]
9818    fn armed_fence_rejects_unknown_host_redacted() {
9819        let cfg = HttpEndpointConfig::from_uri(
9820            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9821        )
9822        .unwrap();
9823        let mut exchange = Exchange::new(Message::default());
9824        exchange.input.set_header(
9825            "CamelHttpUri",
9826            serde_json::Value::String(
9827                "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
9828            ),
9829        );
9830
9831        let err = HttpProducer::resolve_url(&exchange, &cfg)
9832            .expect_err("override host outside the fence must fail resolution");
9833
9834        let message = err.to_string();
9835        assert!(!message.contains("pass"), "userinfo leaked: {message}");
9836        assert!(!message.contains("s3cret"), "query leaked: {message}");
9837    }
9838
9839    #[test]
9840    fn armed_fence_rejects_unparseable_override_redacted() {
9841        let cfg = HttpEndpointConfig::from_uri(
9842            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9843        )
9844        .unwrap();
9845        let mut exchange = Exchange::new(Message::default());
9846        exchange.input.set_header(
9847            "CamelHttpUri",
9848            serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
9849        );
9850
9851        let err = HttpProducer::resolve_url(&exchange, &cfg)
9852            .expect_err("unparseable override outside the fence must fail resolution");
9853
9854        let message = err.to_string();
9855        assert!(
9856            message.contains("allowedUriHosts fence"),
9857            "fence must be named: {message}"
9858        );
9859        assert!(
9860            message.contains("[redacted]"),
9861            "suppression sentinel missing: {message}"
9862        );
9863        assert!(
9864            !message.contains("evil.example.com"),
9865            "host leaked: fail-closed arm must render only the sentinel: {message}"
9866        );
9867        assert!(
9868            !message.contains("fencesecret"),
9869            "password leaked: {message}"
9870        );
9871        assert!(!message.contains("u:"), "userinfo leaked: {message}");
9872    }
9873
9874    #[test]
9875    fn armed_fence_rejects_password_only_userinfo_redacted() {
9876        let cfg = HttpEndpointConfig::from_uri(
9877            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9878        )
9879        .unwrap();
9880        let mut exchange = Exchange::new(Message::default());
9881        exchange.input.set_header(
9882            "CamelHttpUri",
9883            serde_json::Value::String(
9884                "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
9885            ),
9886        );
9887
9888        let err = HttpProducer::resolve_url(&exchange, &cfg)
9889            .expect_err("password-only override outside the fence must fail resolution");
9890
9891        let message = err.to_string();
9892        assert!(
9893            !message.contains("passwordonly"),
9894            "password-only userinfo leaked: {message}"
9895        );
9896        assert!(!message.contains("querysecret"), "query leaked: {message}");
9897        assert!(
9898            message.contains("http://***@evil.example.com/x?[redacted]"),
9899            "masked shape missing: {message}"
9900        );
9901    }
9902
9903    #[test]
9904    fn armed_fence_allows_listed_host() {
9905        let cfg = HttpEndpointConfig::from_uri(
9906            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9907        )
9908        .unwrap();
9909        let mut exchange = Exchange::new(Message::default());
9910        exchange.input.set_header(
9911            "CamelHttpUri",
9912            serde_json::Value::String("http://cdn.example.com/x".to_string()),
9913        );
9914
9915        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9916        assert_eq!(url, "http://cdn.example.com/x");
9917    }
9918
9919    #[test]
9920    fn host_only_entry_permits_any_port() {
9921        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
9922        let mut exchange = Exchange::new(Message::default());
9923        exchange.input.set_header(
9924            "CamelHttpUri",
9925            serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
9926        );
9927
9928        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9929        assert_eq!(url, "http://cdn.example.com:9443/x");
9930    }
9931
9932    #[test]
9933    fn unarmed_endpoint_unchanged() {
9934        let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9935        let mut exchange = Exchange::new(Message::default());
9936        exchange.input.set_header(
9937            "CamelHttpUri",
9938            serde_json::Value::String("http://any.example.com/path".to_string()),
9939        );
9940
9941        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9942        assert_eq!(url, "http://any.example.com/path");
9943    }
9944
9945    #[test]
9946    fn empty_allowlist_fails_endpoint_creation() {
9947        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
9948    }
9949
9950    #[test]
9951    fn malformed_entry_fails_endpoint_creation() {
9952        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
9953    }
9954
9955    #[test]
9956    fn fence_entry_with_path_fails_creation() {
9957        // A trailing path is a typo'd entry: silently narrowing it to the
9958        // hostname would widen or skew the fence. Reject loudly.
9959        assert!(
9960            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
9961        );
9962    }
9963
9964    #[test]
9965    fn fence_entry_with_userinfo_fails_creation() {
9966        assert!(
9967            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
9968        );
9969    }
9970
9971    #[test]
9972    fn ipv6_fence_entry_allows_bracketed_host() {
9973        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
9974        // The textual host forms differ; both parse to the same bracketed
9975        // canonical host (`[::1]`) that the entry stores, so both ride.
9976        for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
9977            let mut exchange = Exchange::new(Message::default());
9978            exchange
9979                .input
9980                .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
9981            let url = HttpProducer::resolve_url(&exchange, &cfg)
9982                .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
9983            assert_eq!(url, uri, "bracketed IPv6 override not honored");
9984        }
9985    }
9986
9987    #[test]
9988    fn dns_case_insensitive_fence_match() {
9989        // The entry is stored ASCII-lowercased, so the mixed-case option
9990        // matches the lowercase override host.
9991        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
9992        let mut exchange = Exchange::new(Message::default());
9993        exchange.input.set_header(
9994            "CamelHttpUri",
9995            serde_json::Value::String("http://cdn.example.com/x".to_string()),
9996        );
9997        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9998        assert_eq!(url, "http://cdn.example.com/x");
9999    }
10000
10001    #[test]
10002    fn fence_allowed_override_query_merges_with_header() {
10003        // Fence pass plus full composition: the override URI query is the
10004        // higher-precedence source, the header pair appends.
10005        let cfg =
10006            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
10007        let mut exchange = Exchange::new(Message::default());
10008        exchange.input.set_header(
10009            "CamelHttpUri",
10010            serde_json::Value::String("http://host.example/api?a=1".to_string()),
10011        );
10012        exchange.input.set_header(
10013            "CamelHttpQuery",
10014            serde_json::Value::String("b=2".to_string()),
10015        );
10016
10017        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10018        assert_eq!(url, "http://host.example/api?a=1&b=2");
10019    }
10020
10021    #[test]
10022    fn empty_header_with_armed_fence_leaves_no_query() {
10023        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
10024        let mut exchange = Exchange::new(Message::default());
10025        exchange.input.set_header(
10026            "CamelHttpUri",
10027            serde_json::Value::String("http://host.example/api".to_string()),
10028        );
10029        exchange
10030            .input
10031            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
10032
10033        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10034        assert_eq!(url, "http://host.example/api");
10035        assert!(!url.contains('?'), "query marker leaked: {url}");
10036    }
10037
10038    #[test]
10039    fn fence_option_is_consumed() {
10040        // A raw query on the base URI plus the fence option; no override
10041        // header. The option is consumed at parse time and must never
10042        // appear in the outbound query.
10043        let cfg =
10044            HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
10045        let exchange = Exchange::new(Message::default());
10046
10047        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10048        assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
10049        assert!(url.contains("x=1"), "authored query lost: {url}");
10050    }
10051
10052    #[tokio::test]
10053    async fn resolve_url_malformed_base_url_errors_no_panic() {
10054        use tower::ServiceExt;
10055
10056        let (url, _handle) = start_test_server().await;
10057        let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
10058        config.allow_internal = true; // test server binds 127.0.0.1
10059        let producer = HttpProducer {
10060            config: Arc::new(config),
10061            client: build_client(&HttpConfig::default(), None),
10062            pinned_cache: Arc::new(PinnedClientCache::new(
10063                PINNED_CLIENT_TTL,
10064                PINNED_CLIENT_MAX_ENTRIES,
10065            )),
10066            http_config: Arc::new(HttpConfig::default()),
10067            runtime: rt(),
10068        };
10069
10070        // First call: malformed base URL propagates as an error through the
10071        // real producer path — no panic, no poisoned state (rc-ph7z2).
10072        let first = producer
10073            .clone()
10074            .oneshot(Exchange::new(Message::default()))
10075            .await;
10076        let err = first.expect_err("malformed base URL must error, not panic");
10077        assert!(
10078            err.to_string().to_lowercase().contains("url"),
10079            "error must name the malformed URL: {err}"
10080        );
10081
10082        // Second call through the SAME producer succeeds — the failure
10083        // left no poisoned state.
10084        let mut exchange = Exchange::new(Message::default());
10085        exchange.input.set_header(
10086            "CamelHttpUri",
10087            serde_json::Value::String(format!("{url}/api")),
10088        );
10089        let response = producer
10090            .oneshot(exchange)
10091            .await
10092            .expect("valid request through same producer must succeed");
10093        let status = response
10094            .input
10095            .header("CamelHttpResponseCode")
10096            .and_then(|v| v.as_u64())
10097            .unwrap();
10098        assert_eq!(status, 200);
10099    }
10100
10101    #[test]
10102    fn resolve_url_bridge_malformed_base_errors_no_panic() {
10103        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10104        cfg.bridge_endpoint = true;
10105        cfg.query_params.push(("k".to_string(), "1".to_string()));
10106        // `from_uri` rejects the malformed authority, so the base is set on
10107        // the stored config directly (same build shape as the scheme-case
10108        // test). The bridge arm's validation-only parse (rc-ph7z2) must
10109        // surface it as an error — no panic.
10110        cfg.base_url = "http://[::1:bad".to_string();
10111        let exchange = Exchange::new(Message::default());
10112
10113        let err = HttpProducer::resolve_url(&exchange, &cfg)
10114            .expect_err("malformed bridge base URL must error");
10115        assert!(
10116            err.to_string().contains("invalid base URL"),
10117            "error must name the invalid base URL: {err}"
10118        );
10119    }
10120
10121    #[test]
10122    fn test_http_producer_helpers_status_and_size_boundaries() {
10123        assert!(HttpProducer::is_ok_status(200, (200, 299)));
10124        assert!(HttpProducer::is_ok_status(299, (200, 299)));
10125        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
10126        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
10127
10128        assert!(!exceeds_max_response_body(10, 10));
10129        assert!(exceeds_max_response_body(11, 10));
10130    }
10131
10132    // -----------------------------------------------------------------------
10133    // Content-Type inference tests
10134    // -----------------------------------------------------------------------
10135
10136    #[allow(clippy::await_holding_lock)]
10137    async fn setup_consumer_on_free_port(
10138        path: &str,
10139    ) -> (
10140        u16,
10141        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
10142        tokio_util::sync::CancellationToken,
10143    ) {
10144        use camel_component_api::ConsumerContext;
10145
10146        // ADR-0070 staged-listener law: bind, KEEP the socket, and stage it
10147        // in the ServerRegistry; the consumer's `get_or_spawn` consumes the
10148        // staged listener, so the port never returns to the ephemeral pool
10149        // between probe and serve (no bind-read-drop race).
10150        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10151        let port = listener.local_addr().unwrap().port();
10152
10153        // Hold the registry test mutex across the whole stage→spawn→ready
10154        // window so a concurrent `ServerRegistry::reset()` cannot evict the
10155        // staged listener between staging and readiness. The guard covers
10156        // stage_listener, the consumer spawn, the readiness poll and the
10157        // tail-yield loop; it releases when this helper returns.
10158        // Poison-recovering acquire: a failed sibling test must not
10159        // cascade — the mutex guards test serialization only, no
10160        // structural invariant, so recovery via into_inner is safe.
10161        let _registry_guard = lock_registry_test_mutex();
10162
10163        ServerRegistry::global()
10164            .stage_listener(listener)
10165            .await
10166            .expect("stage consumer test listener");
10167
10168        let consumer_cfg = HttpServerConfig {
10169            scheme: "http".to_string(),
10170            host: "127.0.0.1".to_string(),
10171            port,
10172            path: path.to_string(),
10173            max_request_body: 2 * 1024 * 1024,
10174            max_response_body: 10 * 1024 * 1024,
10175            max_inflight_requests: 1024,
10176            method: None,
10177            tls_config: None,
10178        };
10179        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
10180
10181        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10182        let token = tokio_util::sync::CancellationToken::new();
10183        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10184
10185        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10186
10187        // Readiness without a fixed wall-clock sleep: poll the registry
10188        // entry live (1ms doubling backoff, 10s deadline), then yield so
10189        // the spawned `start()` completes route registration (that tail
10190        // path has no pending timers — only the registry lock — so
10191        // scheduler yields order it deterministically behind this loop).
10192        wait_for_registry_ready("127.0.0.1", port).await;
10193        for _ in 0..8 {
10194            tokio::task::yield_now().await;
10195        }
10196
10197        (port, rx, token)
10198    }
10199
10200    /// Poll `ServerRegistry::bound_addr(host, port)` until the entry
10201    /// appears: 1ms backoff doubling per iteration, capped at 64ms, with
10202    /// a 10s deadline. Panics with a hint naming the likely causes when
10203    /// the deadline fires.
10204    async fn wait_for_registry_ready(host: &str, port: u16) {
10205        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
10206        let mut backoff = std::time::Duration::from_millis(1);
10207        while ServerRegistry::global().bound_addr(host, port).is_none() {
10208            assert!(
10209                tokio::time::Instant::now() < deadline,
10210                "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
10211            );
10212            tokio::time::sleep(backoff).await;
10213            backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
10214        }
10215    }
10216
10217    #[tokio::test]
10218    #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
10219    async fn readiness_deadline_fires_loud_with_hint() {
10220        // Poll a key no writer can produce. Registry keys come from
10221        // either the listener's resolved IP string (staged path) or the
10222        // caller-provided host verbatim (legacy get_or_spawn path), so a
10223        // synthetic host literal that no test passes is unreachable on
10224        // BOTH paths. Binding and HOLDING the listener (never dropped,
10225        // never staged) additionally keeps its port out of the ephemeral
10226        // pool, so no concurrent test can register that port either.
10227        // (Earlier drafts polled 127.0.0.2 — rejected: macOS exposes only
10228        // 127.0.0.1 and the bind fails there, rc-dwmd; and "localhost" —
10229        // rejected: the legacy host-verbatim path could produce it.)
10230        let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10231        let port = held.local_addr().unwrap().port();
10232        wait_for_registry_ready("httpflake-unreachable-host", port).await;
10233    }
10234
10235    // -----------------------------------------------------------------------
10236    // Readiness vs concurrent registry reset (httpflake, regression RED)
10237    // -----------------------------------------------------------------------
10238
10239    #[tokio::test]
10240    async fn readiness_survives_concurrent_registry_reset() {
10241        let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
10242        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
10243
10244        // Hammer thread: loop legal resets, counting a contention whenever
10245        // its try-lock on the registry test mutex blocks (someone else held
10246        // it). The guard is dropped at each iteration end.
10247        let contended_hammer = std::sync::Arc::clone(&contended);
10248        let stop_hammer = std::sync::Arc::clone(&stop);
10249        let handle = std::thread::spawn(move || {
10250            while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
10251                let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
10252                    Err(_) => {
10253                        contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
10254                        lock_registry_test_mutex()
10255                    }
10256                    Ok(guard) => guard,
10257                };
10258                ServerRegistry::reset();
10259            }
10260        });
10261
10262        // Drop guard: even if a setup panics, stop the hammer and join it so
10263        // the thread never outlives the test.
10264        struct StopHammerOnDrop {
10265            handle: Option<std::thread::JoinHandle<()>>,
10266            stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
10267        }
10268        impl Drop for StopHammerOnDrop {
10269            fn drop(&mut self) {
10270                self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
10271                if let Some(handle) = self.handle.take() {
10272                    let _ = handle.join();
10273                }
10274            }
10275        }
10276        let _hammer_guard = StopHammerOnDrop {
10277            handle: Some(handle),
10278            stop,
10279        };
10280
10281        // Always at least 25 setups on fresh ephemeral ports; continue past
10282        // 25 only until one contended reset is observed; hard cap 50.
10283        let mut setups = 0;
10284        loop {
10285            setups += 1;
10286            let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
10287            drop(rx);
10288            token.cancel();
10289            if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
10290                || setups >= 50
10291            {
10292                break;
10293            }
10294        }
10295
10296        let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
10297        assert!(
10298            contended_hits >= 1,
10299            "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
10300        );
10301    }
10302
10303    #[tokio::test]
10304    async fn test_content_type_inferred_for_json_body() {
10305        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
10306
10307        let client = reqwest::Client::new();
10308        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
10309
10310        let (http_result, _) = tokio::join!(send_fut, async {
10311            if let Some(mut envelope) = rx.recv().await {
10312                envelope.exchange.input.body =
10313                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
10314                if let Some(reply_tx) = envelope.reply_tx {
10315                    let _ = reply_tx.send(Ok(envelope.exchange));
10316                }
10317            }
10318        });
10319
10320        let resp = http_result.unwrap();
10321        assert_eq!(resp.status().as_u16(), 200);
10322        let ct = resp
10323            .headers()
10324            .get("content-type")
10325            .expect("Content-Type header should be present");
10326        assert_eq!(ct, "application/json");
10327        let body = resp.text().await.unwrap();
10328        assert_eq!(body, r#"{"message":"hello"}"#);
10329
10330        token.cancel();
10331    }
10332
10333    #[tokio::test]
10334    async fn test_content_type_inferred_for_text_body() {
10335        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
10336
10337        let client = reqwest::Client::new();
10338        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
10339
10340        let (http_result, _) = tokio::join!(send_fut, async {
10341            if let Some(mut envelope) = rx.recv().await {
10342                envelope.exchange.input.body =
10343                    camel_component_api::Body::Text("plain text response".to_string());
10344                if let Some(reply_tx) = envelope.reply_tx {
10345                    let _ = reply_tx.send(Ok(envelope.exchange));
10346                }
10347            }
10348        });
10349
10350        let resp = http_result.unwrap();
10351        assert_eq!(resp.status().as_u16(), 200);
10352        let ct = resp
10353            .headers()
10354            .get("content-type")
10355            .expect("Content-Type header should be present");
10356        assert_eq!(ct, "text/plain; charset=utf-8");
10357        let body = resp.text().await.unwrap();
10358        assert_eq!(body, "plain text response");
10359
10360        token.cancel();
10361    }
10362
10363    #[tokio::test]
10364    async fn test_content_type_inferred_for_xml_body() {
10365        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
10366
10367        let client = reqwest::Client::new();
10368        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
10369
10370        let (http_result, _) = tokio::join!(send_fut, async {
10371            if let Some(mut envelope) = rx.recv().await {
10372                envelope.exchange.input.body =
10373                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
10374                if let Some(reply_tx) = envelope.reply_tx {
10375                    let _ = reply_tx.send(Ok(envelope.exchange));
10376                }
10377            }
10378        });
10379
10380        let resp = http_result.unwrap();
10381        assert_eq!(resp.status().as_u16(), 200);
10382        let ct = resp
10383            .headers()
10384            .get("content-type")
10385            .expect("Content-Type header should be present");
10386        assert_eq!(ct, "application/xml");
10387        let body = resp.text().await.unwrap();
10388        assert_eq!(body, "<root><item>value</item></root>");
10389
10390        token.cancel();
10391    }
10392
10393    #[tokio::test]
10394    async fn test_no_content_type_for_empty_body() {
10395        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
10396
10397        let client = reqwest::Client::new();
10398        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
10399
10400        let (http_result, _) = tokio::join!(send_fut, async {
10401            if let Some(mut envelope) = rx.recv().await {
10402                envelope.exchange.input.body = camel_component_api::Body::Empty;
10403                if let Some(reply_tx) = envelope.reply_tx {
10404                    let _ = reply_tx.send(Ok(envelope.exchange));
10405                }
10406            }
10407        });
10408
10409        let resp = http_result.unwrap();
10410        assert_eq!(resp.status().as_u16(), 200);
10411        assert!(
10412            resp.headers().get("content-type").is_none(),
10413            "Empty body should not set Content-Type"
10414        );
10415
10416        token.cancel();
10417    }
10418
10419    #[tokio::test]
10420    async fn test_no_content_type_for_raw_bytes_body() {
10421        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
10422
10423        let client = reqwest::Client::new();
10424        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
10425
10426        let (http_result, _) = tokio::join!(send_fut, async {
10427            if let Some(mut envelope) = rx.recv().await {
10428                envelope.exchange.input.body =
10429                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
10430                if let Some(reply_tx) = envelope.reply_tx {
10431                    let _ = reply_tx.send(Ok(envelope.exchange));
10432                }
10433            }
10434        });
10435
10436        let resp = http_result.unwrap();
10437        assert_eq!(resp.status().as_u16(), 200);
10438        assert!(
10439            resp.headers().get("content-type").is_none(),
10440            "Raw Bytes body should not set Content-Type"
10441        );
10442
10443        token.cancel();
10444    }
10445
10446    #[tokio::test]
10447    async fn test_content_type_from_stream_metadata() {
10448        use camel_component_api::{StreamBody, StreamMetadata};
10449        use futures::stream;
10450
10451        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
10452
10453        let client = reqwest::Client::new();
10454        let send_fut = client
10455            .get(format!("http://127.0.0.1:{port}/stream-ct"))
10456            .send();
10457
10458        let (http_result, _) = tokio::join!(send_fut, async {
10459            if let Some(mut envelope) = rx.recv().await {
10460                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
10461                    vec![Ok(bytes::Bytes::from("audio data"))];
10462                let stream = Box::pin(stream::iter(chunks));
10463                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
10464                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
10465                    metadata: StreamMetadata {
10466                        size_hint: None,
10467                        content_type: Some("audio/mpeg".to_string()),
10468                        origin: None,
10469                    },
10470                });
10471                if let Some(reply_tx) = envelope.reply_tx {
10472                    let _ = reply_tx.send(Ok(envelope.exchange));
10473                }
10474            }
10475        });
10476
10477        let resp = http_result.unwrap();
10478        assert_eq!(resp.status().as_u16(), 200);
10479        let ct = resp
10480            .headers()
10481            .get("content-type")
10482            .expect("Content-Type header should be present");
10483        assert_eq!(ct, "audio/mpeg");
10484        let body = resp.text().await.unwrap();
10485        assert_eq!(body, "audio data");
10486
10487        token.cancel();
10488    }
10489
10490    #[tokio::test]
10491    async fn test_user_content_type_overrides_inferred() {
10492        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
10493
10494        let client = reqwest::Client::new();
10495        let send_fut = client
10496            .get(format!("http://127.0.0.1:{port}/override-ct"))
10497            .send();
10498
10499        let (http_result, _) = tokio::join!(send_fut, async {
10500            if let Some(mut envelope) = rx.recv().await {
10501                envelope.exchange.input.body =
10502                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
10503                envelope.exchange.input.set_header(
10504                    "Content-Type",
10505                    serde_json::Value::String("text/html".to_string()),
10506                );
10507                if let Some(reply_tx) = envelope.reply_tx {
10508                    let _ = reply_tx.send(Ok(envelope.exchange));
10509                }
10510            }
10511        });
10512
10513        let resp = http_result.unwrap();
10514        assert_eq!(resp.status().as_u16(), 200);
10515        let ct = resp
10516            .headers()
10517            .get("content-type")
10518            .expect("Content-Type header should be present");
10519        assert_eq!(
10520            ct, "text/html",
10521            "User-set Content-Type should take precedence over inferred type"
10522        );
10523
10524        token.cancel();
10525    }
10526
10527    #[tokio::test]
10528    async fn test_user_content_type_with_bytes_body() {
10529        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
10530
10531        let client = reqwest::Client::new();
10532        let send_fut = client
10533            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
10534            .send();
10535
10536        let (http_result, _) = tokio::join!(send_fut, async {
10537            if let Some(mut envelope) = rx.recv().await {
10538                envelope.exchange.input.body =
10539                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
10540                envelope.exchange.input.set_header(
10541                    "Content-Type",
10542                    serde_json::Value::String("application/json".to_string()),
10543                );
10544                if let Some(reply_tx) = envelope.reply_tx {
10545                    let _ = reply_tx.send(Ok(envelope.exchange));
10546                }
10547            }
10548        });
10549
10550        let resp = http_result.unwrap();
10551        assert_eq!(resp.status().as_u16(), 200);
10552        let ct = resp
10553            .headers()
10554            .get("content-type")
10555            .expect("Content-Type header should be present for Bytes body with user header");
10556        assert_eq!(
10557            ct, "application/json",
10558            "User Content-Type should be sent for Bytes body"
10559        );
10560
10561        token.cancel();
10562    }
10563
10564    // -----------------------------------------------------------------------
10565    // Server monitor tests (GRL-005)
10566    // -----------------------------------------------------------------------
10567
10568    #[tokio::test]
10569    async fn monitor_task_silent_on_clean_exit() {
10570        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
10571        let server_exited = tokio_util::sync::CancellationToken::new();
10572        // Clean exit should complete without panicking or logging errors
10573        monitor_axum_task(
10574            handle,
10575            "127.0.0.1:0".to_string(),
10576            noop_rt(),
10577            "test-monitor".into(),
10578            server_exited.clone(),
10579        )
10580        .await;
10581        // rc-szmob: a clean exit must NOT fail hosted consumers — route
10582        // stops own their termination (no CrashNotification storm on
10583        // graceful process shutdown).
10584        assert!(
10585            !server_exited.is_cancelled(),
10586            "clean server exit must not cancel server_exited"
10587        );
10588    }
10589
10590    #[tokio::test]
10591    async fn monitor_task_handles_panicked_task() {
10592        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
10593            panic!("simulated server crash");
10594        });
10595        let server_exited = tokio_util::sync::CancellationToken::new();
10596        // Should complete without panicking even though the inner task panicked
10597        monitor_axum_task(
10598            handle,
10599            "127.0.0.1:9999".to_string(),
10600            noop_rt(),
10601            "test-monitor".into(),
10602            server_exited.clone(),
10603        )
10604        .await;
10605        // rc-szmob: unexpected exit must cancel the token so every hosted
10606        // consumer fails and supervision engages (ADR-0007).
10607        assert!(
10608            server_exited.is_cancelled(),
10609            "crashed server must cancel server_exited"
10610        );
10611    }
10612
10613    // -----------------------------------------------------------------------
10614    // Credential redaction tests
10615    // -----------------------------------------------------------------------
10616
10617    #[test]
10618    fn http_auth_basic_debug_redacts_password() {
10619        let auth = HttpAuth::Basic {
10620            username: "admin".to_string(),
10621            password: "hunter2".to_string(),
10622        };
10623        let debug = format!("{:?}", auth);
10624        assert!(
10625            !debug.contains("hunter2"),
10626            "password must be redacted: {debug}"
10627        );
10628        assert!(debug.contains("admin"), "username should appear: {debug}");
10629    }
10630
10631    #[test]
10632    fn http_auth_bearer_debug_redacts_token() {
10633        let auth = HttpAuth::Bearer {
10634            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
10635        };
10636        let debug = format!("{:?}", auth);
10637        assert!(
10638            !debug.contains("eyJhbGci"),
10639            "token must be redacted: {debug}"
10640        );
10641    }
10642
10643    #[test]
10644    fn http_auth_none_debug_shows_variant() {
10645        let debug = format!("{:?}", HttpAuth::None);
10646        assert!(
10647            debug.contains("None"),
10648            "None variant should appear: {debug}"
10649        );
10650    }
10651
10652    #[test]
10653    fn http_endpoint_config_debug_redacts_auth_credentials() {
10654        let config = HttpEndpointConfig::from_uri(
10655            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
10656        )
10657        .unwrap();
10658        let debug = format!("{:?}", config);
10659        assert!(
10660            !debug.contains("secret123"),
10661            "password must be redacted in HttpEndpointConfig debug: {debug}"
10662        );
10663    }
10664
10665    #[test]
10666    fn debug_lists_all_public_fields() {
10667        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10668        let debug = format!("{:?}", config);
10669        for field in [
10670            "base_url",
10671            "http_method",
10672            "throw_exception_on_failure",
10673            "ok_status_code_range",
10674            "response_timeout",
10675            "query_params",
10676            "raw_query",
10677            "allow_internal",
10678            "blocked_hosts",
10679            "max_body_size",
10680            "read_timeout_ms",
10681            "max_response_bytes",
10682            "auth",
10683            "token_provider",
10684            "user_agent",
10685            "bridge_endpoint",
10686            "connection_close",
10687            "skip_request_headers",
10688            "skip_response_headers",
10689            "follow_redirects",
10690            "max_redirects",
10691        ] {
10692            assert!(
10693                debug.contains(field),
10694                "Debug output missing field '{field}': {debug}"
10695            );
10696        }
10697    }
10698
10699    // -----------------------------------------------------------------------
10700    // Static file serving tests (Task 5)
10701    // -----------------------------------------------------------------------
10702
10703    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
10704    use tower_http::services::ServeDir;
10705
10706    fn make_test_registry() -> HttpRouteRegistry {
10707        HttpRouteRegistry::new()
10708    }
10709
10710    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
10711        AppState {
10712            registry,
10713            max_request_body: 2 * 1024 * 1024,
10714            max_response_body: 10 * 1024 * 1024,
10715            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
10716        }
10717    }
10718
10719    #[allow(clippy::await_holding_lock)]
10720    #[tokio::test]
10721    async fn test_static_file_serving_serves_file_contents() {
10722        let _guard = lock_registry_test_mutex();
10723        ServerRegistry::reset();
10724
10725        // Create temp dir with test files
10726        let temp_dir =
10727            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
10728        std::fs::create_dir_all(&temp_dir).unwrap();
10729        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
10730        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
10731
10732        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10733
10734        let registry = make_test_registry();
10735        let serve_dir = ServeDir::new(&canonical_dir)
10736            .precompressed_gzip()
10737            .precompressed_br()
10738            .append_index_html_on_directories(true);
10739
10740        let mount = StaticMount {
10741            mount_path: "/".to_string(),
10742            mode: MountMode::Static,
10743            dir: canonical_dir.clone(),
10744            cache_control: "public, max-age=3600".to_string(),
10745            error_pages: std::collections::HashMap::new(),
10746            serve_dir,
10747        };
10748        registry.register_static_mount(mount).await.unwrap();
10749
10750        let state = make_test_state(registry);
10751
10752        // Test serving hello.txt
10753        let req = Request::builder()
10754            .uri("/hello.txt")
10755            .body(AxumBody::empty())
10756            .unwrap();
10757        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
10758        assert_eq!(resp.status(), StatusCode::OK);
10759        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10760            .await
10761            .unwrap();
10762        assert_eq!(&body[..], b"Hello, static world!");
10763
10764        // Test serving style.css
10765        let req = Request::builder()
10766            .uri("/style.css")
10767            .body(AxumBody::empty())
10768            .unwrap();
10769        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
10770        assert_eq!(resp.status(), StatusCode::OK);
10771        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10772            .await
10773            .unwrap();
10774        assert_eq!(&body[..], b"body { color: red; }");
10775
10776        // Test 404 for non-existent file
10777        let req = Request::builder()
10778            .uri("/missing.txt")
10779            .body(AxumBody::empty())
10780            .unwrap();
10781        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
10782        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10783
10784        // Cleanup
10785        std::fs::remove_dir_all(&temp_dir).ok();
10786    }
10787
10788    #[allow(clippy::await_holding_lock)]
10789    #[tokio::test]
10790    async fn test_spa_fallback_serves_index_for_unknown_paths() {
10791        let _guard = lock_registry_test_mutex();
10792        ServerRegistry::reset();
10793
10794        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
10795        std::fs::create_dir_all(&temp_dir).unwrap();
10796        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
10797        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
10798
10799        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10800
10801        let registry = make_test_registry();
10802        let serve_dir = ServeDir::new(&canonical_dir)
10803            .precompressed_gzip()
10804            .precompressed_br()
10805            .append_index_html_on_directories(true);
10806
10807        let mount = StaticMount {
10808            mount_path: "/".to_string(),
10809            mode: MountMode::Spa,
10810            dir: canonical_dir.clone(),
10811            cache_control: "public, max-age=0".to_string(),
10812            error_pages: std::collections::HashMap::new(),
10813            serve_dir,
10814        };
10815        // Register as SPA mount
10816        registry.register_static_mount(mount).await.unwrap();
10817
10818        let state = make_test_state(registry);
10819
10820        // SPA fallback: GET /dashboard with Accept: text/html → index.html
10821        let req = Request::builder()
10822            .method("GET")
10823            .uri("/dashboard")
10824            .header("Accept", "text/html")
10825            .body(AxumBody::empty())
10826            .unwrap();
10827        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
10828        assert_eq!(resp.status(), StatusCode::OK);
10829        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10830            .await
10831            .unwrap();
10832        assert_eq!(&body[..], b"<h1>SPA App</h1>");
10833
10834        // Static file still works: GET /app.js
10835        let req = Request::builder()
10836            .method("GET")
10837            .uri("/app.js")
10838            .body(AxumBody::empty())
10839            .unwrap();
10840        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
10841        assert_eq!(resp.status(), StatusCode::OK);
10842        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10843            .await
10844            .unwrap();
10845        assert_eq!(&body[..], b"console.log('app')");
10846
10847        // No SPA fallback for JSON accept → 404
10848        let req = Request::builder()
10849            .method("GET")
10850            .uri("/api/data")
10851            .header("Accept", "application/json")
10852            .body(AxumBody::empty())
10853            .unwrap();
10854        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
10855        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10856
10857        // No SPA fallback for file extensions → 404
10858        let req = Request::builder()
10859            .method("GET")
10860            .uri("/style.css")
10861            .header("Accept", "text/html")
10862            .body(AxumBody::empty())
10863            .unwrap();
10864        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
10865        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10866
10867        // Cleanup
10868        std::fs::remove_dir_all(&temp_dir).ok();
10869    }
10870
10871    // Regression for rc-zoai: a conditional GET (If-None-Match / If-Modified-Since)
10872    // whose validator matches MUST return 304 Not Modified, not 404. The bug was
10873    // dispatch_static's L92 gate `if resp.status().is_success()` discarding
10874    // ServeDir's legitimate 304 and falling through to the generic 404. The fix
10875    // adds `|| resp.status() == StatusCode::NOT_MODIFIED` to that gate (and the
10876    // matching gate in serve_via_serve_dir so the 304 keeps its Cache-Control).
10877    #[allow(clippy::await_holding_lock)]
10878    async fn run_conditional_get_returns_304(mode: MountMode) {
10879        let _guard = lock_registry_test_mutex();
10880        ServerRegistry::reset();
10881
10882        let temp_dir = std::env::temp_dir().join(format!(
10883            "http_cond_get_{}_{}",
10884            if mode == MountMode::Spa {
10885                "spa"
10886            } else {
10887                "static"
10888            },
10889            std::process::id()
10890        ));
10891        std::fs::create_dir_all(&temp_dir).unwrap();
10892        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
10893
10894        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10895
10896        let registry = make_test_registry();
10897        let serve_dir = ServeDir::new(&canonical_dir)
10898            .precompressed_gzip()
10899            .precompressed_br()
10900            .append_index_html_on_directories(true);
10901
10902        let mount = StaticMount {
10903            mount_path: "/".to_string(),
10904            mode,
10905            dir: canonical_dir.clone(),
10906            cache_control: "public, max-age=3600".to_string(),
10907            error_pages: std::collections::HashMap::new(),
10908            serve_dir,
10909        };
10910        registry.register_static_mount(mount).await.unwrap();
10911
10912        let state = make_test_state(registry);
10913
10914        // 1st request: normal GET → 200, capture validators.
10915        let req = Request::builder()
10916            .method("GET")
10917            .uri("/index.html")
10918            .body(AxumBody::empty())
10919            .unwrap();
10920        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10921        assert_eq!(
10922            resp.status(),
10923            StatusCode::OK,
10924            "first GET should return 200, got {}",
10925            resp.status()
10926        );
10927        // Cache-Control must be attached on 200 (sanity for serve_via_serve_dir).
10928        assert!(
10929            resp.headers().contains_key(http::header::CACHE_CONTROL),
10930            "200 response missing Cache-Control"
10931        );
10932        let etag = resp
10933            .headers()
10934            .get(http::header::ETAG)
10935            .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
10936            .clone();
10937        let last_modified = resp
10938            .headers()
10939            .get(http::header::LAST_MODIFIED)
10940            .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
10941            .clone();
10942        // Consume the body so the response is fully drained.
10943        let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
10944            .await
10945            .unwrap();
10946
10947        // 2nd request: If-None-Match with the captured ETag → 304.
10948        // Unconditional: ETag presence is required (asserted above) so this
10949        // sub-test cannot silently skip on a ServeDir etag_method change.
10950        let req = Request::builder()
10951            .method("GET")
10952            .uri("/index.html")
10953            .header(http::header::IF_NONE_MATCH, etag.clone())
10954            .body(AxumBody::empty())
10955            .unwrap();
10956        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10957        assert_eq!(
10958            resp.status(),
10959            StatusCode::NOT_MODIFIED,
10960            "If-None-Match with matching ETag should return 304, got {}",
10961            resp.status()
10962        );
10963        // RFC 7232 §4.1: 304 SHOULD include Cache-Control (the serve_via_serve_dir fix).
10964        assert!(
10965            resp.headers().contains_key(http::header::CACHE_CONTROL),
10966            "304 (If-None-Match) missing Cache-Control"
10967        );
10968        // RFC 7232 §4.1: 304 SHOULD carry the validators forward. Assert the
10969        // response parts rebuild in serve_via_serve_dir preserves them.
10970        assert_eq!(
10971            resp.headers().get(http::header::ETAG),
10972            Some(&etag),
10973            "304 (If-None-Match) must echo the ETag validator"
10974        );
10975        assert_eq!(
10976            resp.headers().get(http::header::LAST_MODIFIED),
10977            Some(&last_modified),
10978            "304 (If-None-Match) must carry Last-Modified"
10979        );
10980
10981        // 3rd request: If-Modified-Since with the captured Last-Modified → 304.
10982        let req = Request::builder()
10983            .method("GET")
10984            .uri("/index.html")
10985            .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
10986            .body(AxumBody::empty())
10987            .unwrap();
10988        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10989        assert_eq!(
10990            resp.status(),
10991            StatusCode::NOT_MODIFIED,
10992            "If-Modified-Since with matching timestamp should return 304, got {}",
10993            resp.status()
10994        );
10995        assert!(
10996            resp.headers().contains_key(http::header::CACHE_CONTROL),
10997            "304 (If-Modified-Since) missing Cache-Control"
10998        );
10999        assert_eq!(
11000            resp.headers().get(http::header::ETAG),
11001            Some(&etag),
11002            "304 (If-Modified-Since) must carry the ETag validator"
11003        );
11004        assert_eq!(
11005            resp.headers().get(http::header::LAST_MODIFIED),
11006            Some(&last_modified),
11007            "304 (If-Modified-Since) must echo Last-Modified"
11008        );
11009
11010        // Negative control: a PAST If-Modified-Since (before the file's mtime)
11011        // MUST return 200 — proving the 304 path is validator-aware, not a
11012        // blanket "always 304" regression. A future date would correctly yield
11013        // 304 since the file's mtime precedes it; that is RFC-correct 304
11014        // behaviour, not a negative control.
11015        let req = Request::builder()
11016            .method("GET")
11017            .uri("/index.html")
11018            .header(
11019                http::header::IF_MODIFIED_SINCE,
11020                "Wed, 21 Oct 2000 07:28:00 GMT",
11021            )
11022            .body(AxumBody::empty())
11023            .unwrap();
11024        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11025        assert_eq!(
11026            resp.status(),
11027            StatusCode::OK,
11028            "past If-Modified-Since should return 200 (file modified after it), got {}",
11029            resp.status()
11030        );
11031
11032        // Cleanup
11033        std::fs::remove_dir_all(&temp_dir).ok();
11034    }
11035
11036    #[tokio::test]
11037    async fn test_conditional_get_returns_304_static_mode() {
11038        run_conditional_get_returns_304(MountMode::Static).await;
11039    }
11040
11041    #[tokio::test]
11042    async fn test_conditional_get_returns_304_spa_mode() {
11043        run_conditional_get_returns_304(MountMode::Spa).await;
11044    }
11045
11046    #[allow(clippy::await_holding_lock)]
11047    #[tokio::test]
11048    async fn test_error_page_mapping_serves_custom_404() {
11049        let _guard = lock_registry_test_mutex();
11050        ServerRegistry::reset();
11051
11052        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
11053        let errors_dir = temp_dir.join("errors");
11054        std::fs::create_dir_all(&errors_dir).unwrap();
11055        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11056        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
11057
11058        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11059        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
11060
11061        let registry = make_test_registry();
11062        let serve_dir = ServeDir::new(&canonical_dir)
11063            .precompressed_gzip()
11064            .precompressed_br()
11065            .append_index_html_on_directories(true);
11066
11067        let mut error_pages = std::collections::HashMap::new();
11068        error_pages.insert(404, canonical_404);
11069
11070        let mount = StaticMount {
11071            mount_path: "/".to_string(),
11072            mode: MountMode::Static,
11073            dir: canonical_dir.clone(),
11074            cache_control: "public, max-age=0".to_string(),
11075            error_pages,
11076            serve_dir,
11077        };
11078        registry.register_static_mount(mount).await.unwrap();
11079
11080        let state = make_test_state(registry);
11081
11082        // Request non-existent file → custom 404 page
11083        let req = Request::builder()
11084            .method("GET")
11085            .uri("/missing.html")
11086            .body(AxumBody::empty())
11087            .unwrap();
11088        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
11089        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11090        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11091            .await
11092            .unwrap();
11093        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
11094
11095        // Existing file still works
11096        let req = Request::builder()
11097            .method("GET")
11098            .uri("/index.html")
11099            .body(AxumBody::empty())
11100            .unwrap();
11101        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11102        assert_eq!(resp.status(), StatusCode::OK);
11103        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11104            .await
11105            .unwrap();
11106        assert_eq!(&body[..], b"<h1>Home</h1>");
11107
11108        // Cleanup
11109        std::fs::remove_dir_all(&temp_dir).ok();
11110    }
11111
11112    #[tokio::test]
11113    async fn http_consumer_returns_body_and_code_on_stop() {
11114        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
11115        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11116        use tower::ServiceExt;
11117
11118        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
11119        let set_body_step = CompiledStep::Process {
11120            kind_hint: camel_api::SpanKindHint::Internal,
11121            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11122                ex.input.body = Body::Text("nope".into());
11123                Box::pin(async move { Ok(ex) })
11124            }),
11125            body_contract: None,
11126            lifecycle: None,
11127            label: None,
11128            to_uri: None,
11129        };
11130        let set_status_step = CompiledStep::Process {
11131            kind_hint: camel_api::SpanKindHint::Internal,
11132            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11133                ex.input.set_header(
11134                    "CamelHttpResponseCode",
11135                    serde_json::Value::Number(409.into()),
11136                );
11137                Box::pin(async move { Ok(ex) })
11138            }),
11139            body_contract: None,
11140            lifecycle: None,
11141            label: None,
11142            to_uri: None,
11143        };
11144        let pipeline = compose_pipeline_with_handler(
11145            vec![set_body_step, set_status_step, CompiledStep::Stop],
11146            None,
11147            PipelineRuntimeCtx::compile_time(),
11148        );
11149
11150        let ex = Exchange::new(Message::default());
11151        let result = pipeline.oneshot(ex).await;
11152        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
11153        let returned = result.unwrap();
11154        assert_eq!(returned.input.body.as_text(), Some("nope"));
11155        assert_eq!(
11156            returned
11157                .input
11158                .header("CamelHttpResponseCode")
11159                .and_then(|v| v.as_u64()),
11160            Some(409)
11161        );
11162    }
11163
11164    #[tokio::test]
11165    async fn http_consumer_returns_200_when_body_empty_on_stop() {
11166        // After ADR-0024: Stop with no body + no status header produces 200 (same as
11167        // a normal completion with no body). The 204 default is gone — users who
11168        // want 204 set CamelHttpResponseCode=204 explicitly.
11169        //
11170        // This test stays at the pipeline level (consistent with the test above).
11171        // E2E coverage of the full HTTP dispatch path is in
11172        // crates/camel-test/tests/integration_test.rs.
11173        use camel_api::{Exchange, Message};
11174        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11175        use tower::ServiceExt;
11176
11177        let pipeline = compose_pipeline_with_handler(
11178            vec![CompiledStep::Stop],
11179            None,
11180            PipelineRuntimeCtx::compile_time(),
11181        );
11182        let ex = Exchange::new(Message::default());
11183        let result = pipeline.oneshot(ex).await;
11184        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
11185        // Body is default (empty); no CamelHttpResponseCode header was set.
11186        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
11187    }
11188
11189    // -----------------------------------------------------------------------
11190    // Task 5: Method-aware REST dispatch tests
11191    // -----------------------------------------------------------------------
11192
11193    /// Spins up an axum server on a free port with a fresh registry.
11194    /// Returns the port plus the registry so the caller can register
11195    /// REST endpoints directly.
11196    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
11197        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11198        let port = listener.local_addr().unwrap().port();
11199        let registry = HttpRouteRegistry::new();
11200        tokio::spawn(run_axum_server(
11201            listener,
11202            registry.clone(),
11203            2 * 1024 * 1024,
11204            10 * 1024 * 1024,
11205            Arc::new(tokio::sync::Semaphore::new(1024)),
11206            test_rt(),
11207            "test-route".into(),
11208        ));
11209        // Give the server a moment to start accepting.
11210        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
11211        (port, registry)
11212    }
11213
11214    /// Helper for REST integration tests: spawns a responder task that
11215    /// reads from `rx`, writes a fixed `(status, body)` back via the
11216    /// envelope's reply channel, and returns once the test request is
11217    /// satisfied.
11218    fn spawn_responder(
11219        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
11220        status: u16,
11221        body: String,
11222    ) -> tokio::task::JoinHandle<()> {
11223        tokio::spawn(async move {
11224            if let Some(envelope) = rx.recv().await {
11225                let _ = envelope.reply_tx.send(HttpReply {
11226                    status,
11227                    headers: vec![],
11228                    body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
11229                });
11230            }
11231        })
11232    }
11233
11234    #[tokio::test]
11235    async fn method_aware_dispatch_same_path_different_verbs() {
11236        let (port, registry) = spawn_test_server().await;
11237
11238        // Register two REST endpoints on the same path with different
11239        // methods. This is the core scenario REST DSL needs to support:
11240        // GET /users (list) and POST /users (create) must not overwrite
11241        // each other.
11242        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11243        registry
11244            .register_rest_endpoint(
11245                "GET".into(),
11246                vec![PathSegment::Literal("users".into())],
11247                get_tx,
11248            )
11249            .await;
11250
11251        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11252        registry
11253            .register_rest_endpoint(
11254                "POST".into(),
11255                vec![PathSegment::Literal("users".into())],
11256                post_tx,
11257            )
11258            .await;
11259
11260        let get_handle = spawn_responder(get_rx, 200, "list".into());
11261        let post_handle = spawn_responder(post_rx, 201, "create".into());
11262
11263        let client = reqwest::Client::new();
11264
11265        // GET /users → list route
11266        let resp = client
11267            .get(format!("http://127.0.0.1:{port}/users"))
11268            .send()
11269            .await
11270            .unwrap();
11271        assert_eq!(resp.status().as_u16(), 200);
11272        let body = resp.text().await.unwrap();
11273        assert_eq!(body, "list");
11274
11275        // POST /users → create route
11276        let resp = client
11277            .post(format!("http://127.0.0.1:{port}/users"))
11278            .send()
11279            .await
11280            .unwrap();
11281        assert_eq!(resp.status().as_u16(), 201);
11282        let body = resp.text().await.unwrap();
11283        assert_eq!(body, "create");
11284
11285        let _ = tokio::join!(get_handle, post_handle);
11286    }
11287
11288    #[tokio::test]
11289    async fn method_aware_dispatch_templated_path_extracts_params() {
11290        let (port, registry) = spawn_test_server().await;
11291
11292        // Register GET /users/{id} as a templated endpoint. The
11293        // dispatcher should match `/users/42` against the template and
11294        // attach `id=42` to the envelope's path_params.
11295        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11296        registry
11297            .register_rest_endpoint(
11298                "GET".into(),
11299                vec![
11300                    PathSegment::Literal("users".into()),
11301                    PathSegment::Param("id".into()),
11302                ],
11303                tx,
11304            )
11305            .await;
11306
11307        // Spawn a responder that echoes the captured id back in the body
11308        // so the test can verify the param was set.
11309        let handle = tokio::spawn(async move {
11310            if let Some(envelope) = rx.recv().await {
11311                let id = envelope.path_params.get("id").cloned().unwrap_or_default();
11312                let _ = envelope.reply_tx.send(HttpReply {
11313                    status: 200,
11314                    headers: vec![],
11315                    body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
11316                });
11317            }
11318        });
11319
11320        let client = reqwest::Client::new();
11321        let resp = client
11322            .get(format!("http://127.0.0.1:{port}/users/42"))
11323            .send()
11324            .await
11325            .unwrap();
11326        assert_eq!(resp.status().as_u16(), 200);
11327        let body = resp.text().await.unwrap();
11328        assert_eq!(body, "id=42");
11329
11330        let _ = handle.await;
11331    }
11332
11333    #[tokio::test]
11334    async fn method_aware_dispatch_unmatched_method_falls_through() {
11335        // If no REST endpoint matches the method, dispatch must fall
11336        // through to the legacy api_routes lookup or static mounts. With
11337        // nothing else registered, the request gets 404 from static
11338        // dispatch.
11339        let (port, _registry) = spawn_test_server().await;
11340
11341        // Register only GET /users; a DELETE /users request has no match.
11342        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11343        _registry
11344            .register_rest_endpoint(
11345                "GET".into(),
11346                vec![PathSegment::Literal("users".into())],
11347                get_tx,
11348            )
11349            .await;
11350
11351        // Drain the GET channel in the background so the consumer side
11352        // doesn't block (we don't expect any envelopes here).
11353        let drain = tokio::spawn(async move {
11354            let mut get_rx = get_rx;
11355            while get_rx.recv().await.is_some() {}
11356        });
11357
11358        let client = reqwest::Client::new();
11359        let resp = client
11360            .delete(format!("http://127.0.0.1:{port}/users"))
11361            .send()
11362            .await
11363            .unwrap();
11364        assert_eq!(resp.status().as_u16(), 404);
11365
11366        drop(drain);
11367    }
11368
11369    #[tokio::test]
11370    async fn regression_legacy_exact_api_route_still_works() {
11371        // A `http:` route registered without an `httpMethod=` URI param
11372        // lands in the legacy api_routes registry. The dispatcher must
11373        // still find it via exact path lookup. This guards against
11374        // regressions introduced by the new REST-aware dispatch.
11375        let (port, registry) = spawn_test_server().await;
11376
11377        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11378        registry.register_api_route("/legacy/path".into(), tx).await;
11379
11380        let handle = tokio::spawn(async move {
11381            if let Some(envelope) = rx.recv().await {
11382                let _ = envelope.reply_tx.send(HttpReply {
11383                    status: 200,
11384                    headers: vec![],
11385                    body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
11386                });
11387            }
11388        });
11389
11390        let client = reqwest::Client::new();
11391        let resp = client
11392            .get(format!("http://127.0.0.1:{port}/legacy/path"))
11393            .send()
11394            .await
11395            .unwrap();
11396        assert_eq!(resp.status().as_u16(), 200);
11397        let body = resp.text().await.unwrap();
11398        assert_eq!(body, "legacy ok");
11399
11400        let _ = handle.await;
11401    }
11402
11403    #[allow(clippy::await_holding_lock)]
11404    #[tokio::test]
11405    async fn regression_static_mount_still_works() {
11406        // Verify that static file serving still works after the
11407        // dispatch refactor. We register a temp-dir mount and request
11408        // a file from it; the static dispatcher should serve it.
11409        let _guard = lock_registry_test_mutex();
11410        ServerRegistry::reset();
11411
11412        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
11413        std::fs::create_dir_all(&temp_dir).unwrap();
11414        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
11415        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11416
11417        let registry = make_test_registry();
11418        let serve_dir = ServeDir::new(&canonical_dir)
11419            .precompressed_gzip()
11420            .precompressed_br()
11421            .append_index_html_on_directories(true);
11422        let mount = StaticMount {
11423            mount_path: "/".to_string(),
11424            mode: MountMode::Static,
11425            dir: canonical_dir.clone(),
11426            cache_control: "public, max-age=3600".to_string(),
11427            error_pages: std::collections::HashMap::new(),
11428            serve_dir,
11429        };
11430        registry.register_static_mount(mount).await.unwrap();
11431
11432        let state = make_test_state(registry);
11433        let req = Request::builder()
11434            .uri("/regress.txt")
11435            .body(AxumBody::empty())
11436            .unwrap();
11437        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
11438        assert_eq!(resp.status(), StatusCode::OK);
11439        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11440            .await
11441            .unwrap();
11442        assert_eq!(&body[..], b"static works");
11443
11444        std::fs::remove_dir_all(&temp_dir).ok();
11445    }
11446
11447    // -----------------------------------------------------------------------
11448    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
11449    // templated from-URI round-trip. These exercise the real axum dispatch
11450    // path (register → HTTP request → reply) so a regression in any of the
11451    // three critical fixes surfaces as a test failure rather than a silent
11452    // production 404/500.
11453    // -----------------------------------------------------------------------
11454
11455    #[tokio::test]
11456    async fn deregister_one_method_keeps_sibling_verbs() {
11457        // Review C1: stopping the GET /users consumer must NOT tear down the
11458        // live POST /users endpoint. Register both, deregister GET only,
11459        // then verify POST still dispatches.
11460        let (port, registry) = spawn_test_server().await;
11461
11462        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11463        registry
11464            .register_rest_endpoint(
11465                "GET".into(),
11466                vec![PathSegment::Literal("users".into())],
11467                get_tx,
11468            )
11469            .await;
11470
11471        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11472        registry
11473            .register_rest_endpoint(
11474                "POST".into(),
11475                vec![PathSegment::Literal("users".into())],
11476                post_tx,
11477            )
11478            .await;
11479
11480        // Drain GET in the background (no requests expected after deregister).
11481        let drain = tokio::spawn(async move {
11482            let mut get_rx = get_rx;
11483            while get_rx.recv().await.is_some() {}
11484        });
11485
11486        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
11487        registry.unregister_rest_endpoint("GET", "/users").await;
11488        drop(drain);
11489
11490        let post_handle = spawn_responder(post_rx, 201, "create".into());
11491
11492        let client = reqwest::Client::new();
11493        // POST /users must still reach its consumer after GET was removed.
11494        let resp = client
11495            .post(format!("http://127.0.0.1:{port}/users"))
11496            .send()
11497            .await
11498            .unwrap();
11499        assert_eq!(resp.status().as_u16(), 201);
11500        assert_eq!(resp.text().await.unwrap(), "create");
11501
11502        let _ = post_handle.await;
11503    }
11504
11505    #[tokio::test]
11506    async fn dispatch_exact_legacy_beats_rest_template() {
11507        // Review C2: an exact legacy API route (`GET /api/users`, no
11508        // httpMethod) must win over a templated REST route
11509        // (`GET /api/{resource}`) for the request `/api/users`, per spec
11510        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
11511        let (port, registry) = spawn_test_server().await;
11512
11513        // Exact legacy route.
11514        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11515        registry
11516            .register_api_route("/api/users".into(), exact_tx)
11517            .await;
11518        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
11519
11520        // Templated REST route that would ALSO match /api/users.
11521        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11522        registry
11523            .register_rest_endpoint(
11524                "GET".into(),
11525                vec![
11526                    PathSegment::Literal("api".into()),
11527                    PathSegment::Param("resource".into()),
11528                ],
11529                tpl_tx,
11530            )
11531            .await;
11532        // The templated handler must NOT receive the /api/users request. If
11533        // it does, it replies "template-leak" so a future assertion could
11534        // catch it. We do NOT await this task: the exact-match branch wins
11535        // and the templated channel never receives, so awaiting would block
11536        // until the test runtime tears down.
11537        let _tpl_drain = tokio::spawn(async move {
11538            let mut tpl_rx = tpl_rx;
11539            if let Some(env) = tpl_rx.recv().await {
11540                let _ = env.reply_tx.send(HttpReply {
11541                    status: 200,
11542                    headers: vec![],
11543                    body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
11544                });
11545            }
11546        });
11547
11548        let client = reqwest::Client::new();
11549        let resp = client
11550            .get(format!("http://127.0.0.1:{port}/api/users"))
11551            .send()
11552            .await
11553            .unwrap();
11554        assert_eq!(resp.status().as_u16(), 200);
11555        // Exact-match handler answered — not the templated one.
11556        assert_eq!(resp.text().await.unwrap(), "exact");
11557
11558        let _ = exact_handle.await;
11559    }
11560
11561    #[tokio::test]
11562    async fn ambiguous_rest_templates_return_500_not_silent_404() {
11563        // Review C3: two equal-specificity templates that both match one
11564        // request are an ambiguous registration. At runtime this must
11565        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
11566        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
11567        let (port, registry) = spawn_test_server().await;
11568
11569        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11570        registry
11571            .register_rest_endpoint(
11572                "GET".into(),
11573                vec![
11574                    PathSegment::Literal("users".into()),
11575                    PathSegment::Param("id".into()),
11576                ],
11577                a_tx,
11578            )
11579            .await;
11580
11581        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11582        registry
11583            .register_rest_endpoint(
11584                "GET".into(),
11585                vec![
11586                    PathSegment::Literal("users".into()),
11587                    PathSegment::Param("name".into()),
11588                ],
11589                b_tx,
11590            )
11591            .await;
11592
11593        let client = reqwest::Client::new();
11594        let resp = client
11595            .get(format!("http://127.0.0.1:{port}/users/42"))
11596            .send()
11597            .await
11598            .unwrap();
11599        // Ambiguous → 500 (previously a silent 404).
11600        assert_eq!(resp.status().as_u16(), 500);
11601    }
11602
11603    #[test]
11604    fn from_uri_round_trips_templated_path_with_http_method() {
11605        // Review I4: a REST-lowered from-URI like
11606        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
11607        // through HttpServerConfig::from_uri, preserving the templated path
11608        // and the (uppercased) method. This is the binding the DSL lowering
11609        // emits and the consumer reads; it was previously unasserted.
11610        use crate::UriConfig;
11611        let cfg =
11612            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
11613        assert_eq!(cfg.host, "0.0.0.0");
11614        assert_eq!(cfg.port, 8080);
11615        assert_eq!(cfg.path, "/users/{id}");
11616        assert_eq!(cfg.method.as_deref(), Some("GET"));
11617
11618        // Lower-case httpMethod is uppercased (review I5).
11619        let cfg_lc =
11620            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
11621        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
11622        assert_eq!(cfg_lc.path, "/orders");
11623    }
11624
11625    // -----------------------------------------------------------------------
11626    // rc-1dk4: TypeConversionFailed → 400 Bad Request
11627    // -----------------------------------------------------------------------
11628
11629    #[test]
11630    fn type_conversion_failed_maps_to_400() {
11631        let reply = pipeline_error_to_reply(
11632            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
11633            "/api/users",
11634        );
11635        assert_eq!(reply.status, 400);
11636        // Exactly one Content-Type header, application/json
11637        let json_ct = reply
11638            .headers
11639            .iter()
11640            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11641            .count();
11642        assert_eq!(json_ct, 1);
11643        // Body must be structured error JSON with the expected fields
11644        let body = match &reply.body {
11645            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11646            _ => panic!("expected bytes body"),
11647        };
11648        let parsed: serde_json::Value =
11649            serde_json::from_str(&body).expect("body must be valid JSON");
11650        assert_eq!(parsed["error"], "bad_request");
11651        assert_eq!(parsed["message"], "invalid JSON at line 1");
11652    }
11653
11654    #[test]
11655    fn other_error_still_maps_to_500() {
11656        let reply =
11657            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
11658        assert_eq!(reply.status, 500);
11659    }
11660
11661    #[test]
11662    fn unauthenticated_maps_to_401() {
11663        let reply = pipeline_error_to_reply(
11664            CamelError::Unauthenticated("no token".to_string()),
11665            "/api/users",
11666        );
11667        assert_eq!(reply.status, 401);
11668    }
11669
11670    #[test]
11671    fn unauthorized_maps_to_403() {
11672        let reply = pipeline_error_to_reply(
11673            CamelError::Unauthorized("forbidden".to_string()),
11674            "/api/users",
11675        );
11676        assert_eq!(reply.status, 403);
11677    }
11678
11679    #[test]
11680    fn validation_error_maps_to_400() {
11681        let reply = pipeline_error_to_reply(
11682            CamelError::ValidationError("body does not match schema".to_string()),
11683            "/api/users",
11684        );
11685        assert_eq!(reply.status, 400);
11686        let json_ct = reply
11687            .headers
11688            .iter()
11689            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11690            .count();
11691        assert_eq!(json_ct, 1);
11692        let body = match &reply.body {
11693            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11694            _ => panic!("expected bytes body"),
11695        };
11696        let parsed: serde_json::Value =
11697            serde_json::from_str(&body).expect("body must be valid JSON");
11698        assert_eq!(parsed["error"], "validation_error");
11699        assert_eq!(parsed["message"], "body does not match schema");
11700    }
11701
11702    // -----------------------------------------------------------------------
11703    // rc-hlb1q: media negotiation errors → 415 / 406
11704    // -----------------------------------------------------------------------
11705
11706    #[test]
11707    fn finalizer_maps_unsupported_media_type() {
11708        let reply = pipeline_error_to_reply(
11709            CamelError::UnsupportedMediaType {
11710                consumed: "text/plain".to_string(),
11711                declared: "application/json".to_string(),
11712            },
11713            "/x",
11714        );
11715        assert_eq!(reply.status, 415);
11716        let json_ct = reply
11717            .headers
11718            .iter()
11719            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11720            .count();
11721        assert_eq!(json_ct, 1);
11722        let body = match &reply.body {
11723            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11724            _ => panic!("expected bytes body"),
11725        };
11726        let parsed: serde_json::Value =
11727            serde_json::from_str(&body).expect("body must be valid JSON");
11728        assert_eq!(parsed["error"], "unsupported_media_type");
11729        assert_eq!(
11730            parsed["message"],
11731            "consumed text/plain, declared application/json"
11732        );
11733    }
11734
11735    #[test]
11736    fn finalizer_maps_not_acceptable() {
11737        let reply = pipeline_error_to_reply(
11738            CamelError::NotAcceptable {
11739                accept: "application/xml".to_string(),
11740                produced: "application/json".to_string(),
11741            },
11742            "/x",
11743        );
11744        assert_eq!(reply.status, 406);
11745        let json_ct = reply
11746            .headers
11747            .iter()
11748            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11749            .count();
11750        assert_eq!(json_ct, 1);
11751        let body = match &reply.body {
11752            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11753            _ => panic!("expected bytes body"),
11754        };
11755        let parsed: serde_json::Value =
11756            serde_json::from_str(&body).expect("body must be valid JSON");
11757        assert_eq!(parsed["error"], "not_acceptable");
11758        assert_eq!(
11759            parsed["message"],
11760            "accept application/xml, produced application/json"
11761        );
11762    }
11763
11764    #[test]
11765    fn json_error_reply_preserves_empty_message() {
11766        let reply = json_error_reply(400, "bad_request", "".to_string());
11767        assert_eq!(reply.status, 400);
11768        let json_ct = reply
11769            .headers
11770            .iter()
11771            .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11772            .count();
11773        assert_eq!(json_ct, 1);
11774        let body = match &reply.body {
11775            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11776            _ => panic!("expected bytes body"),
11777        };
11778        let parsed: serde_json::Value =
11779            serde_json::from_str(&body).expect("body must be valid JSON");
11780        assert_eq!(parsed["error"], "bad_request");
11781        assert_eq!(parsed["message"], "");
11782    }
11783
11784    #[test]
11785    fn https_consumer_without_tls_cert_errors() {
11786        let endpoint = HttpEndpoint {
11787            uri: "https://0.0.0.0:8443/api".to_string(),
11788            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11789            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11790            client: reqwest::Client::new(),
11791            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11792                PINNED_CLIENT_TTL,
11793                PINNED_CLIENT_MAX_ENTRIES,
11794            )),
11795            http_config: HttpConfig::default(),
11796        };
11797        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11798        let result = endpoint.create_consumer(rt);
11799        assert!(result.is_err(), "expected error for https without tls cert");
11800        if let Err(e) = result {
11801            let msg = e.to_string();
11802            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
11803        }
11804    }
11805
11806    #[test]
11807    fn http_consumer_with_tls_config_errors() {
11808        let endpoint = HttpEndpoint {
11809            uri: "http://0.0.0.0:8080/api".to_string(),
11810            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
11811            server_config: HttpServerConfig::from_uri(
11812                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
11813            )
11814            .unwrap(),
11815            client: reqwest::Client::new(),
11816            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11817                PINNED_CLIENT_TTL,
11818                PINNED_CLIENT_MAX_ENTRIES,
11819            )),
11820            http_config: HttpConfig::default(),
11821        };
11822        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11823        let result = endpoint.create_consumer(rt);
11824        assert!(result.is_err(), "expected error for http with tls config");
11825        if let Err(e) = result {
11826            let msg = e.to_string();
11827            assert!(msg.contains("https"), "error must mention https: {msg}");
11828        }
11829    }
11830
11831    #[test]
11832    fn https_consumer_with_partial_tls_cert_only_errors() {
11833        // tlsCert without tlsKey → tls_config is None at parse time
11834        // → create_consumer sees https:// + no TLS → must error
11835        let server_config =
11836            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
11837        assert!(
11838            server_config.tls_config.is_none(),
11839            "partial tlsCert must not create ServerTlsConfig"
11840        );
11841        let endpoint = HttpEndpoint {
11842            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
11843            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
11844                .unwrap(),
11845            server_config,
11846            client: reqwest::Client::new(),
11847            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11848                PINNED_CLIENT_TTL,
11849                PINNED_CLIENT_MAX_ENTRIES,
11850            )),
11851            http_config: HttpConfig::default(),
11852        };
11853        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11854        let result = endpoint.create_consumer(rt);
11855        assert!(
11856            result.is_err(),
11857            "must error: https:// requires both tlsCert and tlsKey"
11858        );
11859    }
11860
11861    #[test]
11862    fn load_tls_config_parses_valid_pem() {
11863        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
11864        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11865        use camel_component_api::test_support::tls;
11866        let (_, cert_pem, key_pem) = tls::gen_server_cert();
11867        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
11868        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
11869
11870        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
11871        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
11872    }
11873
11874    #[tokio::test(flavor = "multi_thread")]
11875    #[allow(clippy::await_holding_lock)]
11876    async fn consumer_tls_handshake_roundtrip() {
11877        use camel_component_api::test_support::tls;
11878        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11879
11880        // Install rustls crypto provider (aws-lc-rs)
11881        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11882
11883        // Serialize against global ServerRegistry singleton
11884        let _guard = lock_registry_test_mutex();
11885
11886        // Generate CA + server cert
11887        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
11888        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
11889        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
11890        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
11891
11892        // Get ephemeral port
11893        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11894        let port = probe.local_addr().unwrap().port();
11895        drop(probe);
11896
11897        ServerRegistry::reset();
11898
11899        // Create real HttpComponent + endpoint with TLS URI
11900        let component = HttpComponent::new();
11901        let endpoint_ctx = NoOpComponentContext;
11902        let uri = format!(
11903            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
11904            cert_path.to_string_lossy(),
11905            key_path.to_string_lossy(),
11906        );
11907        let endpoint = component
11908            .create_endpoint(&uri, &endpoint_ctx)
11909            .expect("create TLS endpoint");
11910        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
11911
11912        // Start consumer — this calls get_or_spawn with tls_config
11913        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11914        let token = tokio_util::sync::CancellationToken::new();
11915        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
11916        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11917
11918        // Give server time to start
11919        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
11920
11921        // Client with CA cert — REAL verification (no danger_accept_invalid)
11922        let ca_bytes = std::fs::read(&ca_path).unwrap();
11923        let client = reqwest::Client::builder()
11924            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
11925            .build()
11926            .unwrap();
11927
11928        let send_fut = client
11929            .post(format!("https://localhost:{port}/test"))
11930            .body("ping")
11931            .send();
11932
11933        // Handler: receive envelope, reply 200 with "pong" body
11934        let (http_result, _) = tokio::join!(send_fut, async {
11935            if let Some(mut envelope) = rx.recv().await {
11936                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
11937                if let Some(reply_tx) = envelope.reply_tx {
11938                    let _ = reply_tx.send(Ok(envelope.exchange));
11939                }
11940            }
11941        });
11942
11943        let resp = http_result.expect("TLS handshake + request must succeed");
11944
11945        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
11946        let body = resp.text().await.unwrap();
11947        assert_eq!(body, "pong");
11948
11949        token.cancel();
11950    }
11951
11952    #[tokio::test(flavor = "multi_thread")]
11953    #[allow(clippy::await_holding_lock)]
11954    async fn consumer_tls_rejects_client_without_ca() {
11955        use camel_component_api::test_support::tls;
11956        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11957
11958        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11959
11960        // Serialize against global ServerRegistry singleton
11961        let _guard = lock_registry_test_mutex();
11962
11963        let (_, cert_pem, key_pem) = tls::gen_server_cert();
11964        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
11965        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
11966
11967        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11968        let port = probe.local_addr().unwrap().port();
11969        drop(probe);
11970
11971        ServerRegistry::reset();
11972
11973        // Spawn TLS server via real HttpComponent path
11974        let component = HttpComponent::new();
11975        let endpoint_ctx = NoOpComponentContext;
11976        let uri = format!(
11977            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
11978            cert_path.to_string_lossy(),
11979            key_path.to_string_lossy(),
11980        );
11981        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
11982        let mut consumer = endpoint.create_consumer(rt()).unwrap();
11983        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11984        let token = tokio_util::sync::CancellationToken::new();
11985        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
11986        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11987
11988        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
11989
11990        // Client WITHOUT CA cert — must fail TLS verification
11991        let client = reqwest::Client::builder().build().unwrap();
11992
11993        let result = client
11994            .get(format!("https://localhost:{port}/test"))
11995            .send()
11996            .await;
11997
11998        assert!(
11999            result.is_err(),
12000            "must reject without CA — proves real verification"
12001        );
12002
12003        token.cancel();
12004    }
12005
12006    #[test]
12007    fn server_config_partial_tls_cert_without_key() {
12008        // Parse URI with only tlsCert (no tlsKey)
12009        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12010        // Partial params → tls_config must be None
12011        assert!(cfg.tls_config.is_none());
12012    }
12013
12014    #[test]
12015    fn endpoint_uri_options_count_parity() {
12016        // Mirror struct must stay in sync with bespoke from_components parser.
12017        assert_eq!(
12018            HttpEndpointConfig::uri_options().len(),
12019            22,
12020            "HttpEndpointUriConfig #[uri_param] count drifted from parser"
12021        );
12022    }
12023
12024    fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
12025        pairs
12026            .iter()
12027            .map(|(k, v)| {
12028                (
12029                    (*k).to_string(),
12030                    serde_json::Value::String((*v).to_string()),
12031                )
12032            })
12033            .collect()
12034    }
12035
12036    #[test]
12037    fn response_emits_cache_control_via_pragma_warning() {
12038        let headers = make_headers(&[
12039            ("Cache-Control", "public, max-age=3600"),
12040            ("Via", "1.1 myproxy"),
12041            ("Pragma", "no-cache"),
12042            ("Warning", "199 misc"),
12043        ]);
12044        let selected = select_response_headers(&headers, None, None);
12045        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12046        for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
12047            assert!(
12048                names.contains(&expected),
12049                "{expected} should pass through to the response"
12050            );
12051        }
12052    }
12053
12054    #[test]
12055    fn response_excludes_request_only_and_server_owned() {
12056        let headers = make_headers(&[
12057            ("User-Agent", "x"),
12058            ("Accept", "*/*"),
12059            ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
12060        ]);
12061        let selected = select_response_headers(&headers, None, None);
12062        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12063        for excluded in ["User-Agent", "Accept", "Date"] {
12064            assert!(
12065                !names.contains(&excluded),
12066                "{excluded} should NOT appear in the response"
12067            );
12068        }
12069    }
12070
12071    #[test]
12072    fn response_re_derives_content_type() {
12073        let headers = make_headers(&[("Content-Type", "text/plain")]);
12074        let selected = select_response_headers(&headers, Some("application/json".into()), None);
12075        let ct_entries: Vec<&str> = selected
12076            .iter()
12077            .filter(|(k, _)| k == "Content-Type")
12078            .map(|(_, v)| v.as_str())
12079            .collect();
12080        assert_eq!(
12081            ct_entries,
12082            ["application/json"],
12083            "exactly one Content-Type entry, re-derived from user_content_type"
12084        );
12085    }
12086
12087    #[test]
12088    fn response_excludes_camel_headers() {
12089        let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
12090        let selected = select_response_headers(&headers, None, None);
12091        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12092        assert!(
12093            !names.contains(&"CamelHttpPath"),
12094            "Camel-namespace headers must be excluded"
12095        );
12096        assert!(
12097            names.contains(&"Cache-Control"),
12098            "Cache-Control must pass through"
12099        );
12100    }
12101
12102    #[test]
12103    fn response_stringifies_scalar_header_values() {
12104        let mut headers = make_headers(&[("X-Label", "keep")]);
12105        headers.insert("X-Retries".to_string(), serde_json::json!(3));
12106        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12107        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12108        let selected = select_response_headers(&headers, None, None);
12109        let get = |name: &str| -> Option<&str> {
12110            selected
12111                .iter()
12112                .find(|(k, _)| k == name)
12113                .map(|(_, v)| v.as_str())
12114        };
12115        assert_eq!(
12116            get("X-Retries"),
12117            Some("3"),
12118            "integer header must be stringified"
12119        );
12120        assert_eq!(
12121            get("X-Ratio"),
12122            Some("3.5"),
12123            "float header must be stringified"
12124        );
12125        assert_eq!(
12126            get("X-Enabled"),
12127            Some("true"),
12128            "bool header must be stringified"
12129        );
12130        assert_eq!(
12131            get("X-Label"),
12132            Some("keep"),
12133            "string header must pass through"
12134        );
12135    }
12136
12137    #[test]
12138    fn response_drops_null_and_structured_header_values() {
12139        let mut headers = make_headers(&[("X-Keep", "yes")]);
12140        headers.insert("X-Null".to_string(), serde_json::Value::Null);
12141        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12142        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12143        let selected = select_response_headers(&headers, None, None);
12144        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12145        for dropped in ["X-Null", "X-Obj", "X-Arr"] {
12146            assert!(
12147                !names.contains(&dropped),
12148                "{dropped} must not be emitted: no single-value form"
12149            );
12150        }
12151        assert!(names.contains(&"X-Keep"), "scalar headers must survive");
12152    }
12153
12154    #[test]
12155    fn response_stringifies_scalars_despite_excluded_names() {
12156        // Excluded names stay excluded regardless of value type: the policy
12157        // filter runs before stringification, so numeric values cannot smuggle
12158        // content-length or server-owned headers into the reply.
12159        let mut headers = HashMap::new();
12160        headers.insert("Content-Length".to_string(), serde_json::json!(999));
12161        headers.insert("Date".to_string(), serde_json::json!(12345));
12162        let selected = select_response_headers(&headers, None, None);
12163        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12164        assert!(
12165            !names.contains(&"Content-Length"),
12166            "content-length is re-derived by the server"
12167        );
12168        assert!(!names.contains(&"Date"), "date is server-owned");
12169    }
12170
12171    #[test]
12172    fn outbound_stringifies_scalar_header_values() {
12173        let mut headers = make_headers(&[("X-Label", "keep")]);
12174        headers.insert("X-Retries".to_string(), serde_json::json!(3));
12175        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12176        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12177        let outbound = select_outbound_headers(&headers, &[], &[]);
12178        // HeaderName construction lowercases; lookups compare case-blind.
12179        let get = |name: &str| -> Option<String> {
12180            outbound
12181                .accepted
12182                .iter()
12183                .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12184                .map(|(_, v)| v.to_str().unwrap().to_string())
12185        };
12186        assert_eq!(
12187            get("X-Retries").as_deref(),
12188            Some("3"),
12189            "integer header must be stringified"
12190        );
12191        assert_eq!(
12192            get("X-Ratio").as_deref(),
12193            Some("3.5"),
12194            "float header must be stringified"
12195        );
12196        assert_eq!(
12197            get("X-Enabled").as_deref(),
12198            Some("true"),
12199            "bool header must be stringified"
12200        );
12201        assert_eq!(
12202            get("X-Label").as_deref(),
12203            Some("keep"),
12204            "string header must pass through"
12205        );
12206        assert!(outbound.drops.is_empty(), "scalar headers must not drop");
12207    }
12208
12209    #[test]
12210    fn outbound_drops_null_and_structured_header_values() {
12211        let mut headers = make_headers(&[("X-Keep", "yes")]);
12212        headers.insert("X-Null".to_string(), serde_json::Value::Null);
12213        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12214        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12215        let outbound = select_outbound_headers(&headers, &[], &[]);
12216        let has = |name: &str| {
12217            outbound
12218                .accepted
12219                .iter()
12220                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12221        };
12222        assert!(has("X-Keep"), "scalar headers must survive");
12223        for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
12224            let dropped = outbound
12225                .drops
12226                .iter()
12227                .find(|d| d.name == name)
12228                .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
12229            assert_eq!(
12230                dropped.reason, "no scalar string form",
12231                "{name} drop reason must name the value kind absence"
12232            );
12233            assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
12234        }
12235    }
12236
12237    #[test]
12238    fn outbound_stringifies_scalars_despite_excluded_names() {
12239        // Excluded names stay excluded regardless of value type: the policy
12240        // filter runs before stringification, so numeric values cannot smuggle
12241        // hop-by-hop or client-derived headers onto the wire.
12242        let mut headers = HashMap::new();
12243        headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
12244        headers.insert("Host".to_string(), serde_json::json!(12345));
12245        headers.insert("X-Ok".to_string(), serde_json::json!(7));
12246        let outbound = select_outbound_headers(&headers, &[], &[]);
12247        let has = |name: &str| {
12248            outbound
12249                .accepted
12250                .iter()
12251                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12252        };
12253        assert!(
12254            !has("Transfer-Encoding"),
12255            "hop-by-hop header must stay excluded"
12256        );
12257        assert!(!has("Host"), "host is destination-derived");
12258        assert!(has("X-Ok"), "non-excluded scalar must be stringified");
12259        assert!(
12260            outbound
12261                .drops
12262                .iter()
12263                .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
12264            "policy drop must be recorded before coercion"
12265        );
12266    }
12267
12268    #[test]
12269    fn outbound_drops_invalid_names_values_and_skip_config() {
12270        let mut headers = make_headers(&[("X-Good", "fine")]);
12271        headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
12272        headers.insert(
12273            "X-Control-Value".to_string(),
12274            serde_json::json!("line1\nline2"),
12275        );
12276        headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
12277        headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
12278        let skip = vec!["x-secret".to_string()];
12279        let outbound = select_outbound_headers(&headers, &skip, &[]);
12280        let has = |name: &str| {
12281            outbound
12282                .accepted
12283                .iter()
12284                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12285        };
12286        assert!(has("X-Good"), "valid header must survive");
12287        assert!(!has("X Bad Name"), "invalid header name must drop");
12288        assert!(!has("X-Control-Value"), "control-char value must drop");
12289        assert!(!has("X-Secret"), "skipped header must drop");
12290        assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
12291        let reason = |n: &str| {
12292            outbound
12293                .drops
12294                .iter()
12295                .find(|d| d.name == n)
12296                .map(|d| d.reason)
12297        };
12298        assert_eq!(reason("X Bad Name"), Some("invalid header name"));
12299        assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
12300        assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
12301        assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
12302    }
12303
12304    #[test]
12305    fn constructed_header_invalid_value_returns_drop_record() {
12306        let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
12307        let Err(record) = result else {
12308            panic!("invalid value must produce a drop record");
12309        };
12310        assert_eq!(record.reason, "invalid header value");
12311        assert_eq!(record.name, "user-agent");
12312        assert!(record.value_kind.is_none());
12313        let debug = format!("{record:?}");
12314        assert!(
12315            !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
12316            "drop record debug must not leak the value"
12317        );
12318    }
12319
12320    #[test]
12321    fn constructed_header_invalid_name_returns_drop_record() {
12322        let result = constructed_header("bad name", "ok");
12323        let Err(record) = result else {
12324            panic!("invalid name must produce a drop record");
12325        };
12326        assert_eq!(record.reason, "invalid header name");
12327        assert_eq!(record.name, "bad name");
12328        let debug = format!("{record:?}");
12329        assert!(
12330            !debug.contains("ok"),
12331            "drop record debug must not leak the value"
12332        );
12333    }
12334
12335    #[test]
12336    fn constructed_header_valid_pair_roundtrip() {
12337        let result = constructed_header("authorization", "Bearer abc123");
12338        let Ok((name, val)) = result else {
12339            panic!("valid pair must construct");
12340        };
12341        assert_eq!(name.as_str(), "authorization");
12342        let Ok(roundtrip) = val.to_str() else {
12343            panic!("valid value must roundtrip to str");
12344        };
12345        assert_eq!(roundtrip, "Bearer abc123");
12346    }
12347
12348    // -----------------------------------------------------------------------
12349    // Bridge proxy end-to-end integration tests (Task 4.1)
12350    // Fully local + deterministic: raw TCP / in-process consumer / reqwest
12351    // to 127.0.0.1. No public CDN, no httpbin, no network egress.
12352    // -----------------------------------------------------------------------
12353
12354    /// Destination server that captures the outbound request line and the
12355    /// `Host:` header the producer actually sent on the wire. Returns
12356    /// `(host_value, request_line)` so a bridge-proxy test can assert that
12357    /// the producer derived `Host` from the destination (not the exchange)
12358    /// and honoured bridging semantics for the path.
12359    async fn start_host_capturing_destination() -> (
12360        String,
12361        Arc<std::sync::Mutex<Option<(String, String)>>>,
12362        tokio::task::JoinHandle<()>,
12363    ) {
12364        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12365        let port = listener.local_addr().unwrap().port();
12366        let url = format!("http://127.0.0.1:{port}");
12367        let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
12368            Arc::new(std::sync::Mutex::new(None));
12369        let captured_clone = Arc::clone(&captured);
12370        let handle = tokio::spawn(async move {
12371            use tokio::io::{AsyncReadExt, AsyncWriteExt};
12372            if let Ok((mut stream, _)) = listener.accept().await {
12373                let mut buf = vec![0u8; 16384];
12374                let n = stream.read(&mut buf).await.unwrap_or(0);
12375                let request = String::from_utf8_lossy(&buf[..n]).to_string();
12376                if request.contains("\r\n\r\n") {
12377                    let request_line = request.lines().next().unwrap_or("").to_string();
12378                    let host_value = request
12379                        .lines()
12380                        .find(|l| l.to_lowercase().starts_with("host:"))
12381                        .and_then(|l| l.split_once(':'))
12382                        .map(|(_, v)| v.trim().to_string())
12383                        .unwrap_or_default();
12384                    *captured_clone.lock().unwrap() = Some((host_value, request_line));
12385                }
12386                let body = r#"{"echo":"ok"}"#;
12387                let resp = format!(
12388                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
12389                    body.len(),
12390                    body
12391                );
12392                let _ = stream.write_all(resp.as_bytes()).await;
12393            }
12394        });
12395        (url, captured, handle)
12396    }
12397
12398    /// A bridging producer must derive `Host` from the destination URL and
12399    /// ignore the exchange `CamelHttpPath`, matching Apache Camel bridging
12400    /// semantics. The wire-level proof is the raw `Host:` header and request
12401    /// line captured at the destination TCP socket.
12402    #[tokio::test]
12403    async fn bridge_proxy_outbound_host_matches_destination() {
12404        use tower::ServiceExt;
12405
12406        let (url, captured, _handle) = start_host_capturing_destination().await;
12407        // The Host header reqwest derives for http://127.0.0.1:{port} is the
12408        // authority, scheme-stripped: "127.0.0.1:{port}".
12409        let expected_host = url.strip_prefix("http://").unwrap();
12410
12411        let ctx = test_producer_ctx();
12412        let component = HttpComponent::new();
12413        let endpoint_ctx = NoOpComponentContext;
12414        let endpoint = component
12415            .create_endpoint(
12416                &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
12417                &endpoint_ctx,
12418            )
12419            .unwrap();
12420        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
12421
12422        // Exchange carries a stale Host and a CamelHttpPath that bridging
12423        // must drop.
12424        let mut exchange = Exchange::new(Message::default());
12425        exchange.input.set_header("Host", "localhost");
12426        exchange.input.set_header("CamelHttpPath", "/foo");
12427
12428        let result = producer.oneshot(exchange).await;
12429        assert!(result.is_ok(), "producer call failed: {:?}", result);
12430
12431        tokio::time::sleep(Duration::from_millis(100)).await;
12432        let (host_value, request_line) = captured
12433            .lock()
12434            .unwrap()
12435            .take()
12436            .expect("destination capture mutex empty — producer did not reach the destination");
12437
12438        assert_ne!(
12439            host_value, "localhost",
12440            "bridge producer must not forward the exchange Host: localhost"
12441        );
12442        assert_eq!(
12443            host_value, expected_host,
12444            "Host must be derived from the destination authority (no scheme)"
12445        );
12446        assert!(
12447            !request_line.contains("/foo"),
12448            "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
12449        );
12450    }
12451
12452    /// A response header set by the route (`Cache-Control`) must survive to
12453    /// the wire. The assertion is on the reqwest HTTP response — not an
12454    /// in-process HttpReply struct — so it proves the consumer's reply
12455    /// finaliser emitted the header over the socket.
12456    #[tokio::test]
12457    async fn bridge_proxy_route_set_response_header_survives() {
12458        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12459
12460        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12461        let port = listener.local_addr().unwrap().port();
12462        drop(listener);
12463
12464        let component = HttpComponent::new();
12465        let endpoint_ctx = NoOpComponentContext;
12466        let endpoint = component
12467            .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
12468            .unwrap();
12469        let mut consumer = endpoint.create_consumer(rt()).unwrap();
12470
12471        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12472        let token = tokio_util::sync::CancellationToken::new();
12473        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
12474
12475        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12476        tokio::time::sleep(Duration::from_millis(50)).await;
12477
12478        let client = reqwest::Client::new();
12479        let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
12480
12481        // Route sets Cache-Control on the outbound reply (exchange.input is
12482        // the message the reply finaliser reads — see select_response_headers
12483        // at the dispatch site).
12484        let (http_result, _) = tokio::join!(send_fut, async {
12485            if let Some(mut envelope) = rx.recv().await {
12486                envelope
12487                    .exchange
12488                    .input
12489                    .set_header("Cache-Control", "public, max-age=3600");
12490                if let Some(reply_tx) = envelope.reply_tx {
12491                    let _ = reply_tx.send(Ok(envelope.exchange));
12492                }
12493            }
12494        });
12495
12496        let resp = http_result.unwrap();
12497        assert_eq!(resp.status().as_u16(), 200);
12498
12499        let cache_control = resp.headers().get("cache-control");
12500        assert!(
12501            cache_control.is_some(),
12502            "Cache-Control header must survive to the wire response"
12503        );
12504        assert_eq!(
12505            cache_control.unwrap().to_str().unwrap(),
12506            "public, max-age=3600"
12507        );
12508
12509        token.cancel();
12510    }
12511
12512    // -----------------------------------------------------------------------
12513    // credential-sources task 2.3: credential values stay out of diagnostics
12514    // -----------------------------------------------------------------------
12515    //
12516    // camel-http has no request access log (design.md "Redaction sinks",
12517    // ADR-0051). The only diagnostic sink on the failed-auth path is
12518    // `pipeline_error_to_reply`, which renders the (generic) error message and
12519    // the *configured* route path — never the request URI, query string, or
12520    // extracted credential. These tests pin that redact-by-construction
12521    // contract: a sentinel credential presented in a declared source must not
12522    // appear in the reply body nor in any tracing record emitted while the
12523    // request is handled.
12524    //
12525    // Capture scope: `#[traced_test]` installs a per-crate env filter
12526    // (`camel_component_http=trace`), so records from OTHER targets
12527    // (`camel_auth`, `camel_processor`, etc.) are NOT captured here. The
12528    // redaction contract for those crates is guarded by their own tests.
12529    // Revisit this capture scope if camel-auth ever logs on the auth path.
12530    use camel_api::security_policy::CredentialSource;
12531    use camel_auth::credential_source::extract_token_from_exchange;
12532    use camel_auth::native_auth::NativeCredentialStore;
12533    use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
12534
12535    // Sentinel credential values — test fixtures only, not real secrets.
12536    const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; // allow-secret
12537    const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; // allow-secret
12538    const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; // allow-secret
12539
12540    /// Build the exchange the consumer would build for a request envelope:
12541    /// standard Camel HTTP headers plus title-cased forwarded request headers.
12542    fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
12543        let mut msg = Message::default();
12544        msg.set_header(
12545            "CamelHttpMethod",
12546            serde_json::Value::String(envelope.method.clone()),
12547        );
12548        msg.set_header(
12549            "CamelHttpPath",
12550            serde_json::Value::String(envelope.path.clone()),
12551        );
12552        msg.set_header(
12553            "CamelHttpQuery",
12554            serde_json::Value::String(envelope.query.clone()),
12555        );
12556        for (k, v) in &envelope.headers {
12557            if let Ok(val_str) = v.to_str() {
12558                msg.set_header(
12559                    title_case_header(k.as_str()),
12560                    serde_json::Value::String(val_str.to_string()),
12561                );
12562            }
12563        }
12564        Exchange::new(msg)
12565    }
12566
12567    /// Register a route whose responder authenticates each request against an
12568    /// empty native store, so every presented credential fails lookup with
12569    /// `Unauthenticated` (401). Restores the pre-AuthContext `RolePolicy`
12570    /// authentication step (extract per `sources` → authenticate → deny) so the
12571    /// credential-extraction redaction contract is exercised on a real
12572    /// authentication failure.
12573    async fn spawn_failing_auth_route(
12574        registry: &HttpRouteRegistry,
12575        path: &str,
12576        sources: Vec<CredentialSource>,
12577    ) {
12578        let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
12579            NativeCredentialStore::try_new(vec![]).unwrap(),
12580        ));
12581        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12582        registry.register_api_route(path.to_string(), tx).await;
12583        let path_owned = path.to_string();
12584        tokio::spawn(async move {
12585            while let Some(envelope) = rx.recv().await {
12586                let exchange = envelope_to_exchange(&envelope);
12587                let reply_tx = envelope.reply_tx;
12588                let result: Result<(), CamelError> = async {
12589                    let token = extract_token_from_exchange(&exchange, &sources)
12590                        .map(|extracted| extracted.token)
12591                        .ok_or_else(|| {
12592                            CamelError::Unauthenticated("no credential in any source".into())
12593                        })?;
12594                    authenticator.authenticate_bearer(&token).await?;
12595                    Ok(())
12596                }
12597                .await;
12598                let reply = match result {
12599                    Ok(()) => HttpReply {
12600                        status: 200,
12601                        headers: vec![],
12602                        body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
12603                    },
12604                    Err(e) => pipeline_error_to_reply(e, &path_owned),
12605                };
12606                let _ = reply_tx.send(reply);
12607            }
12608        });
12609    }
12610
12611    /// Whether any tracing record captured so far (process-wide) contains
12612    /// `needle`. `#[traced_test]` installs a global subscriber writing to a
12613    /// shared buffer, so logs from spawned request-handling tasks are included.
12614    fn captured_logs_contain(needle: &str) -> bool {
12615        let buf = tracing_test::internal::global_buf().lock().unwrap();
12616        String::from_utf8_lossy(&buf).contains(needle)
12617    }
12618
12619    #[tracing_test::traced_test]
12620    #[tokio::test]
12621    async fn error_context_redacts_query_sentinel() {
12622        let (port, registry) = spawn_test_server().await;
12623        spawn_failing_auth_route(
12624            &registry,
12625            "/secure-query",
12626            vec![CredentialSource::QueryParam {
12627                param: "token".to_string(),
12628            }],
12629        )
12630        .await;
12631
12632        let client = reqwest::Client::new();
12633        let resp = client
12634            // allow-secret: `token` is the declared query-source param name, not a credential
12635            .get(format!(
12636                "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
12637            ))
12638            .send()
12639            .await
12640            .unwrap();
12641
12642        assert_eq!(resp.status().as_u16(), 401);
12643        let body = resp.text().await.unwrap();
12644        assert_eq!(body, "Unauthorized");
12645        assert!(
12646            !body.contains(SENTINEL_QRY_42),
12647            "reply body must not contain the query credential"
12648        );
12649        assert!(
12650            !captured_logs_contain(SENTINEL_QRY_42),
12651            "no tracing record during request handling may render the query credential"
12652        );
12653        // Permanent positive control: the failed-auth warn! must be captured.
12654        // If the per-crate env filter ever stops matching, this fails loudly
12655        // instead of letting the sentinel assertions pass vacuously.
12656        assert!(
12657            captured_logs_contain("Authentication failed"),
12658            "positive control: the failed-auth warn! must be captured by the test subscriber"
12659        );
12660    }
12661
12662    #[tracing_test::traced_test]
12663    #[tokio::test]
12664    async fn error_context_redacts_cookie_sentinel() {
12665        let (port, registry) = spawn_test_server().await;
12666        spawn_failing_auth_route(
12667            &registry,
12668            "/secure-cookie",
12669            vec![CredentialSource::Cookie {
12670                name: "session".to_string(),
12671            }],
12672        )
12673        .await;
12674
12675        let client = reqwest::Client::new();
12676        let resp = client
12677            .get(format!("http://127.0.0.1:{port}/secure-cookie"))
12678            .header("Cookie", format!("session={SENTINEL_CKY_7}"))
12679            .send()
12680            .await
12681            .unwrap();
12682
12683        assert_eq!(resp.status().as_u16(), 401);
12684        let body = resp.text().await.unwrap();
12685        assert_eq!(body, "Unauthorized");
12686        assert!(
12687            !body.contains(SENTINEL_CKY_7),
12688            "reply body must not contain the cookie credential"
12689        );
12690        assert!(
12691            !captured_logs_contain(SENTINEL_CKY_7),
12692            "no tracing record during request handling may render the cookie credential"
12693        );
12694    }
12695
12696    #[tracing_test::traced_test]
12697    #[tokio::test]
12698    async fn error_reply_no_credential_value() {
12699        let (port, registry) = spawn_test_server().await;
12700        spawn_failing_auth_route(
12701            &registry,
12702            "/secure-bad",
12703            vec![CredentialSource::Cookie {
12704                name: "session".to_string(),
12705            }],
12706        )
12707        .await;
12708
12709        let client = reqwest::Client::new();
12710        let resp = client
12711            .get(format!("http://127.0.0.1:{port}/secure-bad"))
12712            .header("Cookie", format!("session={SENTINEL_BAD_1}"))
12713            .send()
12714            .await
12715            .unwrap();
12716
12717        assert_eq!(resp.status().as_u16(), 401);
12718        let body = resp.text().await.unwrap();
12719        assert_eq!(body, "Unauthorized");
12720        assert!(
12721            !body.contains(SENTINEL_BAD_1),
12722            "reply body must not contain the credential value"
12723        );
12724        assert!(
12725            !captured_logs_contain(SENTINEL_BAD_1),
12726            "error logs must not render the credential value"
12727        );
12728    }
12729
12730    // -----------------------------------------------------------------------
12731    // Pinned-client-cache producer-path behavioral tests
12732    // (openspec/changes/http-pinned-client-cache — scenarios: producers share
12733    // the endpoint cache, hostname requests build one client while the entry
12734    // stays retrievable, IP-literal requests bypass the cache)
12735    // -----------------------------------------------------------------------
12736
12737    /// Local responder that accepts any number of HTTP/1.1 connections on an
12738    /// ephemeral 127.0.0.1 port and answers each with a fixed 200 response.
12739    /// Unlike [`start_host_capturing_destination`], which serves exactly one
12740    /// connection, this loop keeps accepting so cache-reuse tests can drive
12741    /// several requests through one destination. Returns
12742    /// `(base_url, JoinHandle)`.
12743    async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12744        use tokio::io::AsyncWriteExt;
12745
12746        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12747            .await
12748            .expect("bind ephemeral 127.0.0.1 listener");
12749        let port = listener.local_addr().expect("local addr").port();
12750        let base_url = format!("http://localhost:{port}");
12751        let handle = tokio::spawn(async move {
12752            while let Ok((mut conn, _)) = listener.accept().await {
12753                let _ = conn
12754                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
12755                    .await;
12756                let _ = conn.shutdown().await;
12757            }
12758        });
12759        (base_url, handle)
12760    }
12761
12762    /// rc-0li3: local HTTPS responder — the TLS twin of
12763    /// [`spawn_multi_accept_200`]. Accepts any number of TLS connections on
12764    /// an ephemeral 127.0.0.1 port and answers each with a fixed 200. The
12765    /// certificate comes from `camel_component_api::test_support`
12766    /// (SANs: localhost, 127.0.0.1, ::1); clients run with
12767    /// `tls.insecure = true`.
12768    async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12769        use tokio::io::AsyncWriteExt;
12770
12771        let (_ca_pem, cert_pem, key_pem) =
12772            camel_component_api::test_support::tls::gen_server_cert();
12773        let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
12774            .collect::<Result<_, _>>()
12775            .expect("parse server cert pem");
12776        let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
12777            .expect("parse server key pem")
12778            .expect("server key present");
12779        // Explicit provider: the process default is ambiguous when multiple
12780        // crates pull rustls feature sets; the graph enables aws-lc-rs.
12781        let provider =
12782            std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
12783        let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
12784            .with_safe_default_protocol_versions()
12785            .expect("safe default protocol versions")
12786            .with_no_client_auth()
12787            .with_single_cert(certs, key)
12788            .expect("build rustls server config");
12789        let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
12790
12791        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12792            .await
12793            .expect("bind ephemeral 127.0.0.1 listener");
12794        let port = listener.local_addr().expect("local addr").port();
12795        let base_url = format!("https://localhost:{port}");
12796        let handle = tokio::spawn(async move {
12797            while let Ok((conn, _)) = listener.accept().await {
12798                let acceptor = acceptor.clone();
12799                tokio::spawn(async move {
12800                    if let Ok(mut tls) = acceptor.accept(conn).await {
12801                        let _ = tls
12802                            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
12803                            .await;
12804                        let _ = tls.shutdown().await;
12805                    }
12806                });
12807            }
12808        });
12809        (base_url, handle)
12810    }
12811
12812    /// Port of a [`spawn_multi_accept_200`] base URL, for tests that must
12813    /// target a different authority (the 127.0.0.1 literal) on the same
12814    /// listener.
12815    fn responder_port(base_url: &str) -> u16 {
12816        url::Url::parse(base_url)
12817            .expect("responder base URL parses")
12818            .port()
12819            .expect("responder base URL carries an explicit port")
12820    }
12821
12822    /// Build an endpoint literal whose outbound config points at
12823    /// `base_url` (with `allowInternal=true` so the SSRF resolver permits
12824    /// loopback) and whose pinned-client cache is the caller-owned Arc, so
12825    /// build counts stay observable across producers.
12826    fn endpoint_with_shared_cache(
12827        base_url: &str,
12828        pinned_cache: &Arc<PinnedClientCache>,
12829    ) -> HttpEndpoint {
12830        let uri = format!("{base_url}?allowInternal=true");
12831        HttpEndpoint {
12832            uri: uri.clone(),
12833            config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
12834            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
12835            client: reqwest::Client::new(),
12836            pinned_cache: Arc::clone(pinned_cache),
12837            http_config: HttpConfig::default(),
12838        }
12839    }
12840
12841    #[tokio::test]
12842    async fn producers_share_endpoint_cache() {
12843        use tower::ServiceExt;
12844
12845        let (base_url, _handle) = spawn_multi_accept_200().await;
12846        let pinned_cache = Arc::new(PinnedClientCache::new(
12847            PINNED_CLIENT_TTL,
12848            PINNED_CLIENT_MAX_ENTRIES,
12849        ));
12850
12851        let ctx = test_producer_ctx();
12852        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
12853        let producer_a = endpoint.create_producer(rt(), &ctx);
12854        let producer_b = endpoint.create_producer(rt(), &ctx);
12855
12856        // Each producer sends one exchange whose resolved URL is the
12857        // endpoint's localhost base URL (a domain name → pinned-client path).
12858        for producer in [producer_a, producer_b] {
12859            let producer = producer.expect("create producer");
12860            let exchange = Exchange::new(Message::default());
12861            let reply = producer.oneshot(exchange).await;
12862            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12863        }
12864
12865        assert_eq!(
12866            pinned_cache.build_count(),
12867            1,
12868            "both producers must hit the same shared cache entry; a second \
12869             build means sharing is broken"
12870        );
12871    }
12872
12873    #[tokio::test]
12874    async fn producer_repeated_hostname_requests_build_one_client() {
12875        use tower::ServiceExt;
12876
12877        let (base_url, _handle) = spawn_multi_accept_200().await;
12878        let pinned_cache = Arc::new(PinnedClientCache::new(
12879            PINNED_CLIENT_TTL,
12880            PINNED_CLIENT_MAX_ENTRIES,
12881        ));
12882        let ctx = test_producer_ctx();
12883        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
12884        let producer = endpoint
12885            .create_producer(rt(), &ctx)
12886            .expect("create producer");
12887
12888        // Two sequential hostname requests — the cached pinned client stays
12889        // retrievable between them, so no second build may happen.
12890        for i in 0..2 {
12891            let exchange = Exchange::new(Message::default());
12892            let reply = producer.clone().oneshot(exchange).await;
12893            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
12894        }
12895
12896        assert_eq!(
12897            pinned_cache.build_count(),
12898            1,
12899            "repeated hostname requests must reuse the one pinned client; \
12900             0 builds means the producer bypassed the cache, more than 1 \
12901             means the entry was dropped"
12902        );
12903    }
12904
12905    #[tokio::test]
12906    async fn ip_literal_request_never_enters_cache() {
12907        use tower::ServiceExt;
12908
12909        let (base_url, _handle) = spawn_multi_accept_200().await;
12910        let pinned_cache = Arc::new(PinnedClientCache::new(
12911            PINNED_CLIENT_TTL,
12912            PINNED_CLIENT_MAX_ENTRIES,
12913        ));
12914
12915        let ctx = test_producer_ctx();
12916        let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
12917        let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
12918        let producer = endpoint
12919            .create_producer(rt(), &ctx)
12920            .expect("create producer");
12921
12922        let exchange = Exchange::new(Message::default());
12923        let reply = producer.oneshot(exchange).await;
12924        assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12925
12926        assert_eq!(
12927            pinned_cache.build_count(),
12928            0,
12929            "an IP-literal URL must use the shared unpinned client and \
12930             never enter the pinned cache"
12931        );
12932    }
12933
12934    #[tokio::test]
12935    async fn test_component_endpoints_share_pinned_cache() {
12936        use tower::ServiceExt;
12937
12938        let component = HttpComponent::new();
12939        let (base_url, _handle) = spawn_multi_accept_200().await;
12940        let baseline = component.pinned_cache.build_count();
12941
12942        let ctx = test_producer_ctx();
12943        let endpoint_ctx = NoOpComponentContext;
12944        for uri in [
12945            format!("{base_url}/a?allowInternal=true&k=a"),
12946            format!("{base_url}/b?allowInternal=true&k=b"),
12947        ] {
12948            let endpoint = component
12949                .create_endpoint(&uri, &endpoint_ctx)
12950                .expect("create endpoint");
12951            let producer = endpoint
12952                .create_producer(rt(), &ctx)
12953                .expect("create producer");
12954            let exchange = Exchange::new(Message::default());
12955            let reply = producer.oneshot(exchange).await;
12956            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12957        }
12958
12959        assert_eq!(
12960            component.pinned_cache.build_count() - baseline,
12961            1,
12962            "endpoints created by one component must share its pinned cache; \
12963             0 builds means the endpoints bypassed it, more than 1 means \
12964             per-endpoint caches came back"
12965        );
12966    }
12967
12968    #[tokio::test]
12969    async fn test_dynamic_resolution_sequence_hits_shared_cache() {
12970        use tower::ServiceExt;
12971
12972        let component = HttpComponent::new();
12973        let (base_url, _handle) = spawn_multi_accept_200().await;
12974        let baseline = component.pinned_cache.build_count();
12975
12976        let ctx = test_producer_ctx();
12977        let endpoint_ctx = NoOpComponentContext;
12978        for i in 0..3 {
12979            let endpoint = component
12980                .create_endpoint(
12981                    &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
12982                    &endpoint_ctx,
12983                )
12984                .expect("create endpoint");
12985            let producer = endpoint
12986                .create_producer(rt(), &ctx)
12987                .expect("create producer");
12988            let exchange = Exchange::new(Message::default());
12989            let reply = producer.oneshot(exchange).await;
12990            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
12991        }
12992
12993        assert_eq!(
12994            component.pinned_cache.build_count() - baseline,
12995            1,
12996            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
12997             must reuse the component's one pinned cache entry; 0 builds \
12998             means the endpoints bypassed it, more than 1 means \
12999             per-endpoint caches came back"
13000        );
13001    }
13002
13003    /// rc-0li3: BEHAVIORAL https sharing pin — two https endpoints created
13004    /// through one `HttpsComponent` drive real TLS requests through the
13005    /// component's single pinned cache. A regression that reintroduces
13006    /// per-endpoint `PinnedClientCache::new` inside
13007    /// `HttpsComponent::create_endpoint` leaves the component cache at
13008    /// delta 0 and fails this test (the structural ptr_eq test cannot see
13009    /// that).
13010    #[tokio::test]
13011    async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
13012        use tower::ServiceExt;
13013
13014        let http_config = HttpConfig {
13015            tls: Some(crate::config::TlsConfig {
13016                enabled: true,
13017                insecure: true,
13018                ..Default::default()
13019            }),
13020            ..Default::default()
13021        };
13022        let component = HttpsComponent::with_config(http_config);
13023        let (base_url, _handle) = spawn_tls_multi_accept_200().await;
13024        let baseline = component.pinned_cache.build_count();
13025
13026        let ctx = test_producer_ctx();
13027        let endpoint_ctx = NoOpComponentContext;
13028        for uri in [
13029            format!("{base_url}/a?allowInternal=true&k=a"),
13030            format!("{base_url}/b?allowInternal=true&k=b"),
13031        ] {
13032            let endpoint = component
13033                .create_endpoint(&uri, &endpoint_ctx)
13034                .expect("create https endpoint");
13035            let producer = endpoint
13036                .create_producer(rt(), &ctx)
13037                .expect("create producer");
13038            let exchange = Exchange::new(Message::default());
13039            let reply = producer.oneshot(exchange).await;
13040            assert!(reply.is_ok(), "https request failed: {reply:?}");
13041        }
13042
13043        assert_eq!(
13044            component.pinned_cache.build_count() - baseline,
13045            1,
13046            "endpoints of one HttpsComponent must share its pinned cache over \
13047             real https requests; 0 builds means the endpoints bypassed it \
13048             (per-endpoint cache regression), more than 1 means \
13049             per-endpoint caches came back"
13050        );
13051    }
13052
13053    #[test]
13054    fn test_https_component_owns_distinct_cache() {
13055        let http = HttpComponent::new();
13056        let https = HttpsComponent::new();
13057
13058        assert!(
13059            !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
13060            "http and https components must each own their own pinned cache"
13061        );
13062
13063        let endpoint_ctx = NoOpComponentContext;
13064        let _ = http
13065            .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
13066            .expect("http endpoint");
13067        let _ = https
13068            .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
13069            .expect("https endpoint");
13070
13071        assert_eq!(
13072            http.pinned_cache.build_count(),
13073            0,
13074            "endpoint creation must not build a pinned client"
13075        );
13076        assert_eq!(
13077            https.pinned_cache.build_count(),
13078            0,
13079            "endpoint creation must not build a pinned client"
13080        );
13081    }
13082
13083    #[test]
13084    fn test_component_constructor_builds_one_unpinned_client() {
13085        let baseline = build_client_call_count();
13086
13087        let _http = HttpComponent::new();
13088        assert_eq!(
13089            build_client_call_count() - baseline,
13090            1,
13091            "HttpComponent::new() must build exactly one shared unpinned client"
13092        );
13093
13094        let _https = HttpsComponent::new();
13095        assert_eq!(
13096            build_client_call_count() - baseline,
13097            2,
13098            "HttpsComponent::new() must build exactly one more shared unpinned client"
13099        );
13100    }
13101
13102    #[test]
13103    fn test_component_endpoints_share_unpinned_client() {
13104        let component = HttpComponent::new();
13105        let baseline = build_client_call_count();
13106
13107        let endpoint_ctx = NoOpComponentContext;
13108        for uri in [
13109            "http://localhost:1/a?allowInternal=true",
13110            "http://localhost:1/b?allowInternal=true",
13111        ] {
13112            let _endpoint = component
13113                .create_endpoint(uri, &endpoint_ctx)
13114                .expect("create endpoint");
13115        }
13116
13117        assert_eq!(
13118            build_client_call_count() - baseline,
13119            0,
13120            "create_endpoint must clone the component's shared unpinned client, \
13121             never build a fresh one"
13122        );
13123    }
13124
13125    #[test]
13126    fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
13127        let component = HttpComponent::new();
13128        let baseline = build_client_call_count();
13129
13130        let ctx = test_producer_ctx();
13131        let endpoint_ctx = NoOpComponentContext;
13132        for i in 0..3 {
13133            let endpoint = component
13134                .create_endpoint(
13135                    &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
13136                    &endpoint_ctx,
13137                )
13138                .expect("create endpoint");
13139            let _producer = endpoint
13140                .create_producer(rt(), &ctx)
13141                .expect("create producer");
13142        }
13143
13144        assert_eq!(
13145            build_client_call_count() - baseline,
13146            0,
13147            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13148             must reuse the component's shared unpinned client and build \
13149             no additional clients"
13150        );
13151    }
13152}