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: query bytes (authored `raw_query` and
148/// programmatic `query_params`) may carry credentials. The display-surface
149/// Debug renders the raw view blanket-masked (mirroring
150/// `redact_url_for_diagnostics`) and programmatic values masked, mirroring
151/// `UriComponents`' sensitive-value masking. Wire fidelity is unaffected.
152impl std::fmt::Debug for HttpEndpointConfig {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_struct("HttpEndpointConfig")
155            .field("base_url", &mask_base_url_userinfo(&self.base_url))
156            .field("http_method", &self.http_method)
157            .field(
158                "throw_exception_on_failure",
159                &self.throw_exception_on_failure,
160            )
161            .field("ok_status_code_range", &self.ok_status_code_range)
162            .field("response_timeout", &self.response_timeout)
163            .field(
164                "query_params",
165                &self
166                    .query_params
167                    .iter()
168                    .map(|(key, _)| (key, "***"))
169                    .collect::<Vec<_>>(),
170            )
171            .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
172            .field("allow_internal", &self.allow_internal)
173            .field("blocked_hosts", &self.blocked_hosts)
174            .field("max_body_size", &self.max_body_size)
175            .field("read_timeout_ms", &self.read_timeout_ms)
176            .field("max_response_bytes", &self.max_response_bytes)
177            .field("auth", &self.auth)
178            .field("token_provider", &self.token_provider)
179            .field("user_agent", &self.user_agent)
180            .field("bridge_endpoint", &self.bridge_endpoint)
181            .field("connection_close", &self.connection_close)
182            .field("skip_request_headers", &self.skip_request_headers)
183            .field("skip_response_headers", &self.skip_response_headers)
184            .field("follow_redirects", &self.follow_redirects)
185            .field("max_redirects", &self.max_redirects)
186            .field("allowed_uri_hosts", &self.allowed_uri_hosts)
187            .finish()
188    }
189}
190
191#[derive(Clone, PartialEq)]
192pub enum HttpAuth {
193    None,
194    Basic { username: String, password: String },
195    Bearer { token: String },
196}
197
198impl std::fmt::Debug for HttpAuth {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        match self {
201            HttpAuth::None => f.write_str("None"),
202            HttpAuth::Basic { username, .. } => f
203                .debug_struct("Basic")
204                .field("username", username)
205                .field("password", &"***")
206                .finish(),
207            HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
208        }
209    }
210}
211
212/// Whether `key` names a camel-http endpoint option consumed at parse time.
213///
214/// Single metadata-driven owner of OUTBOUND option filtering (ADR-0041):
215/// derived from the `#[uri_param]` metadata behind
216/// [`HttpEndpointConfig::uri_options`], so the raw query filter consumes
217/// exactly the keys the component documents — no duplicated handwritten
218/// key lists. `from_components`'s manual typed parsing stays direct and
219/// unchanged; this predicate never re-wires it.
220fn is_consumed_option(key: &str) -> bool {
221    HttpEndpointConfig::uri_options()
222        .iter()
223        .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
224}
225
226impl UriConfig for HttpEndpointConfig {
227    /// Returns "http" as the primary scheme (also accepts "https")
228    fn scheme() -> &'static str {
229        "http"
230    }
231
232    fn from_uri(uri: &str) -> Result<Self, CamelError> {
233        let parts = parse_uri(uri)?;
234        Self::from_components(parts)
235    }
236
237    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
238        // Validate scheme - accept both http and https
239        if parts.scheme != "http" && parts.scheme != "https" {
240            return Err(CamelError::InvalidUri(format!(
241                "expected scheme 'http' or 'https', got '{}'",
242                parts.scheme
243            )));
244        }
245
246        // Construct base_url from scheme + path
247        // e.g., "http://localhost:8080/api" from scheme "http" and path "//localhost:8080/api"
248        let base_url = format!("{}:{}", parts.scheme, parts.path);
249
250        let http_method = parts.params.get("httpMethod").cloned();
251
252        let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
253            Some(v) => parse_bool_param_http(v).map_err(|e| {
254                CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
255            })?,
256            None => true,
257        };
258
259        // Parse status code range from "start-end" format (e.g., "200-299")
260        let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
261            Some(v) => parse_ok_status_code_range(v)?,
262            None => (200, 299),
263        };
264
265        let response_timeout = match parts.params.get("responseTimeout") {
266            Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
267                CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
268            })?),
269            None => None,
270        };
271
272        // SSRF protection settings
273        let allow_internal = match parts.params.get("allowInternal") {
274            Some(v) => parse_bool_param_http(v).map_err(|e| {
275                CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
276            })?,
277            None => false, // Default: block private IPs
278        };
279
280        // Parse comma-separated blocked hosts
281        let blocked_hosts = parts
282            .params
283            .get("blockedHosts")
284            .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
285            .unwrap_or_default();
286
287        let max_body_size = match parts.params.get("maxBodySize") {
288            Some(v) => v.parse::<usize>().map_err(|e| {
289                CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
290            })?,
291            None => 10 * 1024 * 1024, // Default: 10MB
292        };
293
294        let read_timeout_ms = match parts.params.get("readTimeout") {
295            Some(v) => v.parse::<u64>().map_err(|e| {
296                CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
297            })?,
298            None => 30_000, // Default: 30s
299        };
300
301        let max_response_bytes = match parts.params.get("maxResponseBytes") {
302            Some(v) => v.parse::<usize>().map_err(|e| {
303                CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
304            })?,
305            None => 10 * 1024 * 1024, // Default: 10MB
306        };
307
308        let auth = parse_auth_from_params(&parts.params)?;
309
310        let user_agent = parts.params.get("userAgent").cloned();
311
312        if parts.params.contains_key("cookieHandling") {
313            return Err(CamelError::InvalidUri(
314                "cookieHandling is not supported".into(),
315            ));
316        }
317
318        let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
319            Some(v) => parse_bool_param_http(v).map_err(|e| {
320                CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
321            })?,
322            None => false,
323        };
324
325        let connection_close = match parts.params.get("connectionClose") {
326            Some(v) => parse_bool_param_http(v).map_err(|e| {
327                CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
328            })?,
329            None => false,
330        };
331
332        let skip_request_headers = parts
333            .params
334            .get("skipRequestHeaders")
335            .map(|v| {
336                v.split(',')
337                    .map(str::trim)
338                    .filter(|s| !s.is_empty())
339                    .map(|s| s.to_ascii_lowercase())
340                    .collect::<Vec<_>>()
341            })
342            .unwrap_or_default();
343
344        let skip_response_headers = parts
345            .params
346            .get("skipResponseHeaders")
347            .map(|v| {
348                v.split(',')
349                    .map(str::trim)
350                    .filter(|s| !s.is_empty())
351                    .map(|s| s.to_ascii_lowercase())
352                    .collect::<Vec<_>>()
353            })
354            .unwrap_or_default();
355
356        let follow_redirects = match parts.params.get("followRedirects") {
357            Some(v) => parse_bool_param_http(v).map_err(|e| {
358                CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
359            })?,
360            None => false,
361        };
362
363        let max_redirects = match parts.params.get("maxRedirects") {
364            Some(v) => v.parse::<usize>().map_err(|e| {
365                CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
366            })?,
367            None => 10,
368        };
369
370        // CamelHttpUri host fence: parsed eagerly so a malformed or empty
371        // allowlist fails endpoint creation (fail-closed), not resolution.
372        let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
373            Some(v) => Some(parse_allowed_uri_hosts(v)?),
374            None => None,
375        };
376
377        // Authored pairs ride raw_query verbatim (the sole carrier);
378        // query_params is programmatic-only — never auto-populated from
379        // URI leftovers. Consumed option keys are filtered at
380        // serialization time by `is_consumed_option`.
381        let raw_query = parts.raw_query.clone();
382
383        Ok(Self {
384            base_url,
385            http_method,
386            throw_exception_on_failure,
387            ok_status_code_range,
388            response_timeout,
389            query_params: Vec::new(),
390            raw_query,
391            allow_internal,
392            blocked_hosts,
393            max_body_size,
394            read_timeout_ms,
395            max_response_bytes,
396            auth,
397            token_provider: None,
398            user_agent,
399            bridge_endpoint,
400            connection_close,
401            skip_request_headers,
402            skip_response_headers,
403            follow_redirects,
404            max_redirects,
405            allowed_uri_hosts,
406        })
407    }
408}
409
410/// Private container for macro-derived `uri_options()` and `metadata()`.
411///
412/// Mirrors the URI query parameters parsed by `HttpEndpointConfig::from_components`.
413/// `HttpEndpointConfig` holds typed fields (tuples, `Duration`, `HttpAuth`,
414/// `Arc<dyn TokenProvider>`) that the derive cannot represent, so metadata
415/// derivation targets this inner type whose fields are all URI-param-compatible.
416#[derive(Debug, Clone, UriConfig)]
417#[allow(dead_code)]
418#[uri_scheme = "http"]
419#[uri_config(
420    skip_impl,
421    metadata(
422        scheme = "http",
423        description = "HTTP client and server component",
424        producer,
425        consumer,
426        streaming
427    ),
428    crate = "camel_component_api"
429)]
430struct HttpEndpointUriConfig {
431    #[allow(dead_code)]
432    _base_url: String,
433
434    #[uri_param(
435        name = "httpMethod",
436        desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
437    )]
438    http_method: Option<String>,
439
440    #[uri_param(
441        name = "throwExceptionOnFailure",
442        default = "true",
443        desc = "Throw on non-2xx status"
444    )]
445    throw_exception_on_failure: bool,
446
447    #[uri_param(
448        name = "okStatusCodeRange",
449        default = "200-299",
450        desc = "Success status code range"
451    )]
452    ok_status_code_range: String,
453
454    #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
455    response_timeout: Option<u64>,
456
457    #[uri_param(
458        name = "connectTimeout",
459        desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
460    )]
461    connect_timeout: Option<u64>,
462
463    #[uri_param(
464        name = "allowInternal",
465        default = "false",
466        desc = "Allow private/internal network destinations (SSRF)"
467    )]
468    allow_internal: bool,
469
470    #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
471    blocked_hosts: Option<String>,
472
473    #[uri_param(
474        name = "maxBodySize",
475        default = "10485760",
476        desc = "Max request/response body bytes"
477    )]
478    max_body_size: u64,
479
480    #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
481    read_timeout: Option<u64>,
482
483    #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
484    max_response_bytes: Option<u64>,
485
486    #[uri_param(
487        name = "authMethod",
488        kind = "enum:Basic,Bearer",
489        desc = "Authentication method"
490    )]
491    auth_method: Option<String>,
492
493    #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
494    auth_username: Option<String>,
495
496    #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
497    auth_password: Option<String>,
498
499    #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
500    auth_bearer_token: Option<String>,
501
502    #[uri_param(name = "userAgent", desc = "User-Agent header")]
503    user_agent: Option<String>,
504
505    #[uri_param(
506        name = "bridgeEndpoint",
507        default = "false",
508        desc = "Bridge endpoint mode"
509    )]
510    bridge_endpoint: bool,
511
512    #[uri_param(
513        name = "connectionClose",
514        default = "false",
515        desc = "Send Connection: close"
516    )]
517    connection_close: bool,
518
519    #[uri_param(
520        name = "skipRequestHeaders",
521        desc = "Comma-separated request headers to skip"
522    )]
523    skip_request_headers: Option<String>,
524
525    #[uri_param(
526        name = "skipResponseHeaders",
527        desc = "Comma-separated response headers to skip"
528    )]
529    skip_response_headers: Option<String>,
530
531    #[uri_param(
532        name = "followRedirects",
533        default = "false",
534        desc = "Follow HTTP redirects"
535    )]
536    follow_redirects: bool,
537
538    #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
539    max_redirects: u64,
540
541    #[uri_param(
542        name = "allowedUriHosts",
543        desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
544    )]
545    allowed_uri_hosts: Option<String>,
546}
547
548impl HttpEndpointConfig {
549    /// Component metadata for the http/https scheme, derived from the
550    /// `#[uri_param]` fields on `HttpEndpointUriConfig`.
551    pub fn metadata() -> ComponentMetadata {
552        HttpEndpointUriConfig::metadata()
553    }
554
555    /// URI option definitions, derived from `#[uri_param]` fields.
556    pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
557        HttpEndpointUriConfig::uri_options()
558    }
559}
560
561fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
562    let Some(method) = params.get("authMethod") else {
563        return Ok(HttpAuth::None);
564    };
565
566    if method.eq_ignore_ascii_case("none") {
567        return Ok(HttpAuth::None);
568    }
569
570    if method.eq_ignore_ascii_case("basic") {
571        let username = params.get("authUsername").cloned().ok_or_else(|| {
572            CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
573        })?;
574        let password = params.get("authPassword").cloned().ok_or_else(|| {
575            CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
576        })?;
577        return Ok(HttpAuth::Basic { username, password });
578    }
579
580    if method.eq_ignore_ascii_case("bearer") {
581        let token = params.get("authBearerToken").cloned().ok_or_else(|| {
582            CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
583        })?;
584        return Ok(HttpAuth::Bearer { token });
585    }
586
587    Err(CamelError::InvalidUri(format!(
588        "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
589    )))
590}
591
592fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
593    match value.to_ascii_lowercase().as_str() {
594        "true" | "1" | "yes" => Ok(true),
595        "false" | "0" | "no" => Ok(false),
596        _ => Err(CamelError::InvalidUri(format!(
597            "invalid boolean value: '{value}'"
598        ))),
599    }
600}
601
602impl HttpEndpointConfig {
603    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
604        let parts = parse_uri(uri)?;
605        let mut endpoint = Self::from_components(parts.clone())?;
606        if endpoint.response_timeout.is_none() {
607            endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
608        }
609        if !parts.params.contains_key("allowInternal") {
610            endpoint.allow_internal = config.allow_internal;
611        }
612        if !parts.params.contains_key("blockedHosts") {
613            endpoint.blocked_hosts = config.blocked_hosts.clone();
614        }
615        if !parts.params.contains_key("maxBodySize") {
616            endpoint.max_body_size = config.max_body_size;
617        }
618        if !parts.params.contains_key("readTimeout") {
619            endpoint.read_timeout_ms = config.read_timeout_ms;
620        }
621        if !parts.params.contains_key("maxResponseBytes") {
622            endpoint.max_response_bytes = config.max_response_bytes;
623        }
624        if !parts.params.contains_key("okStatusCodeRange")
625            && let Some(range) = &config.ok_status_code_range
626        {
627            endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
628        }
629        if !parts.params.contains_key("followRedirects") {
630            endpoint.follow_redirects = config.follow_redirects;
631        }
632        if !parts.params.contains_key("maxRedirects") {
633            endpoint.max_redirects = config.max_redirects.unwrap_or(10);
634        }
635
636        Ok(endpoint)
637    }
638}
639
640// ---------------------------------------------------------------------------
641// HttpServerConfig
642// ---------------------------------------------------------------------------
643
644/// Configuration for an HTTP server (consumer) endpoint.
645#[derive(Debug, Clone)]
646pub struct HttpServerConfig {
647    /// URI scheme ("http" or "https") parsed from the endpoint URI.
648    pub scheme: String,
649    /// Bind address, e.g. "0.0.0.0" or "127.0.0.1".
650    pub host: String,
651    /// TCP port to listen on.
652    pub port: u16,
653    /// URL path this consumer handles, e.g. "/orders".
654    pub path: String,
655    /// Maximum request body size in bytes.
656    pub max_request_body: usize,
657    /// Maximum response body size for materializing streams in bytes.
658    pub max_response_body: usize,
659    /// Maximum number of in-flight requests handled concurrently by this server.
660    pub max_inflight_requests: usize,
661    /// HTTP method this consumer handles (e.g. `"GET"`). When `Some`,
662    /// the consumer registers as a method-aware REST endpoint and the
663    /// path is treated as a template (e.g. `/users/{id}` is matched
664    /// against any `/users/<value>`). When `None`, the consumer
665    /// registers in the legacy path-only `api_routes` registry.
666    /// Extracted from the `httpMethod=` URI param at config build time.
667    pub method: Option<String>,
668    /// Server-side TLS config. Populated from `tlsCert`/`tlsKey` URI params.
669    /// `None` for plain HTTP servers.
670    pub tls_config: Option<crate::config::ServerTlsConfig>,
671}
672
673impl UriConfig for HttpServerConfig {
674    /// Returns "http" as the primary scheme (also accepts "https")
675    fn scheme() -> &'static str {
676        "http"
677    }
678
679    fn from_uri(uri: &str) -> Result<Self, CamelError> {
680        let parts = parse_uri(uri)?;
681        Self::from_components(parts)
682    }
683
684    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
685        // Validate scheme - accept both http and https
686        if parts.scheme != "http" && parts.scheme != "https" {
687            return Err(CamelError::InvalidUri(format!(
688                "expected scheme 'http' or 'https', got '{}'",
689                parts.scheme
690            )));
691        }
692
693        // parts.path is everything after the scheme colon, e.g. "//0.0.0.0:8080/orders"
694        // Strip leading "//"
695        let authority_and_path = parts.path.trim_start_matches('/');
696
697        // Split on the first "/" to separate "host:port" from "/path"
698        let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
699            (&authority_and_path[..idx], &authority_and_path[idx..])
700        } else {
701            (authority_and_path, "/")
702        };
703
704        let path = if path_suffix.is_empty() {
705            "/"
706        } else {
707            path_suffix
708        }
709        .to_string();
710
711        // Parse host:port from authority
712        let (host, port) = if let Some(colon) = authority.rfind(':') {
713            let port_str = &authority[colon + 1..];
714            match port_str.parse::<u16>() {
715                Ok(p) => (authority[..colon].to_string(), p),
716                Err(_) => {
717                    return Err(CamelError::InvalidUri(format!(
718                        "invalid port '{}' in authority",
719                        port_str
720                    )));
721                }
722            }
723        } else {
724            // Default port based on scheme: 443 for https, 80 for http
725            let default_port = if parts.scheme == "https" { 443 } else { 80 };
726            (authority.to_string(), default_port)
727        };
728
729        let max_request_body = parts
730            .params
731            .get("maxRequestBody")
732            .and_then(|v| v.parse::<usize>().ok())
733            .unwrap_or(2 * 1024 * 1024); // Default: 2MB
734
735        let max_response_body = parts
736            .params
737            .get("maxResponseBody")
738            .and_then(|v| v.parse::<usize>().ok())
739            .unwrap_or(10 * 1024 * 1024); // Default: 10MB
740
741        let max_inflight_requests = parts
742            .params
743            .get("maxInflightRequests")
744            .and_then(|v| v.parse::<usize>().ok())
745            .unwrap_or(1024);
746
747        // Uppercase-normalize so a hand-written `httpMethod=get` matches the
748        // uppercase method the dispatcher compares against (axum's
749        // `req.method().to_string()` yields "GET"). Without this, a
750        // lower-case `httpMethod` would never match and silently 404.
751        // Review I5.
752        let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
753
754        Ok(Self {
755            scheme: parts.scheme,
756            host,
757            port,
758            path,
759            max_request_body,
760            max_response_body,
761            max_inflight_requests,
762            method,
763            tls_config: {
764                let cert = parts.params.get("tlsCert").cloned();
765                let key = parts.params.get("tlsKey").cloned();
766                match (cert, key) {
767                    (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
768                        cert_path: c,
769                        key_path: k,
770                    }),
771                    (None, None) => None,
772                    _ => None, // partial — enforced in create_consumer, not here
773                }
774            },
775        })
776    }
777}
778
779impl HttpServerConfig {
780    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
781        let parts = parse_uri(uri)?;
782        let mut server = Self::from_components(parts.clone())?;
783        if !parts.params.contains_key("maxRequestBody") {
784            server.max_request_body = config.max_request_body;
785        }
786        if !parts.params.contains_key("maxResponseBody") {
787            // Default max_response_body is 10MB via HttpConfig::default().max_body_size.
788            server.max_response_body = config.max_body_size;
789        }
790        Ok(server)
791    }
792}
793
794// ---------------------------------------------------------------------------
795// RequestEnvelope / HttpReply
796// ---------------------------------------------------------------------------
797
798/// Body of the HTTP response: already-materialized bytes or a lazy stream.
799///
800/// **Internal plumbing** — subject to change without notice.
801pub enum HttpReplyBody {
802    Bytes(bytes::Bytes),
803    Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
804}
805
806/// An inbound HTTP request sent from the Axum dispatch handler to an
807/// `HttpConsumer` receive loop.
808///
809/// **Internal plumbing** — subject to change without notice.
810pub struct RequestEnvelope {
811    pub method: String,
812    pub path: String,
813    pub query: String,
814    pub headers: http::HeaderMap,
815    pub body: StreamBody,
816    /// Path parameters extracted from a REST template match, e.g.
817    /// `id=42` for a request to `/users/42` matched against
818    /// `/users/{id}`. Empty for non-REST requests or for literal
819    /// template matches. The consumer turns these into
820    /// `CamelHttpPath_<param>` headers on the Exchange (expert guidance E2).
821    pub path_params: std::collections::HashMap<String, String>,
822    pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
823}
824
825/// The HTTP response that `HttpConsumer` sends back to the Axum handler.
826///
827/// **Internal plumbing** — subject to change without notice.
828pub struct HttpReply {
829    pub status: u16,
830    pub headers: Vec<(String, String)>,
831    pub body: HttpReplyBody,
832}
833
834// ---------------------------------------------------------------------------
835// HttpRouteRegistry / ServerRegistry
836// ---------------------------------------------------------------------------
837
838type ServerKey = (String, u16);
839
840/// Handle to a running Axum server on one interface/port.
841struct ServerHandle {
842    registry: HttpRouteRegistry,
843    /// Actual local address of the served listening socket (differs from the
844    /// configured `host:port` when spawning from a staged/pre-bound listener).
845    bound_addr: std::net::SocketAddr,
846    max_request_body: usize,
847    max_response_body: usize,
848    max_inflight_requests: usize,
849    is_tls: bool,
850    tls_cert_path: Option<String>,
851    tls_key_path: Option<String>,
852    /// JoinHandle for the monitor_axum_task wrapper. `is_finished()` is the
853    /// dead-server eviction signal in `get_or_spawn`.
854    monitor_task: tokio::task::JoinHandle<()>,
855    // Retained so the reload handler (Task 7) can call reload_from_config()
856    // to hot-swap certs without restarting the server.
857    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
858    tls_source: Option<ServerTlsSource>,
859}
860
861/// Internal registry state: live server entries plus pre-bound listeners
862/// staged for consumption by the next spawn on the same key.
863#[derive(Default)]
864struct RegistryState {
865    entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
866    staged: HashMap<ServerKey, tokio::net::TcpListener>,
867}
868
869/// Process-global registry mapping (host, port) → running Axum server handle.
870pub struct ServerRegistry {
871    inner: Mutex<RegistryState>,
872}
873
874impl ServerRegistry {
875    /// Returns the global singleton.
876    pub fn global() -> &'static Self {
877        static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
878        INSTANCE.get_or_init(|| ServerRegistry {
879            inner: Mutex::new(RegistryState::default()),
880        })
881    }
882
883    /// Returns route registry for `port`, spawning new Axum server if
884    /// none is running on that port yet.
885    #[allow(clippy::too_many_arguments)]
886    pub async fn get_or_spawn(
887        &'static self,
888        host: &str,
889        port: u16,
890        max_request_body: usize,
891        max_response_body: usize,
892        max_inflight_requests: usize,
893        runtime: Arc<dyn RuntimeObservability>,
894        route_id: String,
895        tls_config: Option<crate::config::ServerTlsConfig>,
896    ) -> Result<HttpRouteRegistry, CamelError> {
897        self.get_or_spawn_internal(
898            host,
899            port,
900            max_request_body,
901            max_response_body,
902            max_inflight_requests,
903            runtime,
904            route_id,
905            tls_config,
906            None,
907        )
908        .await
909    }
910
911    /// Like [`ServerRegistry::get_or_spawn`], but serves `listener` instead
912    /// of binding `host:port`. The registry key is derived from the listener's
913    /// actual local address, so callers must query that port afterwards. If an
914    /// entry for the key already holds a live server, the same compatibility
915    /// checks as `get_or_spawn` apply and the entry is reused; the passed
916    /// listener is simply dropped.
917    #[allow(clippy::too_many_arguments)]
918    pub async fn get_or_spawn_with_listener(
919        &'static self,
920        listener: tokio::net::TcpListener,
921        max_request_body: usize,
922        max_response_body: usize,
923        max_inflight_requests: usize,
924        runtime: Arc<dyn RuntimeObservability>,
925        route_id: String,
926        tls_config: Option<crate::config::ServerTlsConfig>,
927    ) -> Result<HttpRouteRegistry, CamelError> {
928        let addr = listener
929            .local_addr()
930            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
931        self.get_or_spawn_internal(
932            &addr.ip().to_string(),
933            addr.port(),
934            max_request_body,
935            max_response_body,
936            max_inflight_requests,
937            runtime,
938            route_id,
939            tls_config,
940            Some(listener),
941        )
942        .await
943    }
944
945    /// Stage a pre-bound listener so the next `get_or_spawn` for its
946    /// `(ip, port)` key serves this socket instead of binding a new one.
947    ///
948    /// The staged listener is consumed by exactly one spawn: the exact-key
949    /// `get_or_spawn` takes it under the registry lock, eliminating the bind
950    /// window between a port probe and server startup (itest-bound-ports).
951    pub async fn stage_listener(
952        &'static self,
953        listener: tokio::net::TcpListener,
954    ) -> Result<(), CamelError> {
955        let addr = listener
956            .local_addr()
957            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
958        let host = addr.ip().to_string();
959        use std::collections::hash_map::Entry;
960        let mut guard = self.inner.lock().map_err(|_| {
961            CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
962        })?;
963        match guard.staged.entry((host.clone(), addr.port())) {
964            Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
965                "listener already staged for {host}:{}",
966                addr.port()
967            ))),
968            Entry::Vacant(slot) => {
969                slot.insert(listener);
970                Ok(())
971            }
972        }
973    }
974
975    /// Returns the bound address of the live server entry for `(host, port)`,
976    /// if one is initialized.
977    pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
978        let guard = self.inner.lock().ok()?;
979        guard
980            .entries
981            .get(&(host.to_string(), port))
982            .and_then(|cell| cell.get())
983            .map(|handle| handle.bound_addr)
984    }
985
986    #[allow(clippy::too_many_arguments)]
987    async fn get_or_spawn_internal(
988        &'static self,
989        host: &str,
990        port: u16,
991        max_request_body: usize,
992        max_response_body: usize,
993        max_inflight_requests: usize,
994        runtime: Arc<dyn RuntimeObservability>,
995        route_id: String,
996        tls_config: Option<crate::config::ServerTlsConfig>,
997        provided: Option<tokio::net::TcpListener>,
998    ) -> Result<HttpRouteRegistry, CamelError> {
999        let host_owned = host.to_string();
1000        let key = (host.to_string(), port);
1001
1002        let cell = {
1003            let mut guard = self.inner.lock().map_err(|_| {
1004                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1005            })?;
1006            // Evict dead server so a fresh one can spawn (matches gRPC D-L2 pattern).
1007            // The monitor task awaits the server task, so monitor_task.is_finished()
1008            // is a reliable proxy for the server being gone (either crashed or aborted).
1009            if let Some(existing) = guard.entries.get(&key)
1010                && let Some(handle) = existing.get()
1011                && handle.monitor_task.is_finished()
1012            {
1013                // Deregister TLS reload handler so a respawned HTTPS server
1014                // doesn't reload stale cert config from the crashed handler.
1015                if handle.is_tls {
1016                    let scheme = if handle.is_tls { "https" } else { "http" };
1017                    camel_component_api::tls_source::TlsReloadRegistry::global()
1018                        .unregister(scheme, host, port);
1019                }
1020                guard.entries.remove(&key);
1021            }
1022            guard
1023                .entries
1024                .entry(key)
1025                .or_insert_with(|| Arc::new(OnceCell::new()))
1026                .clone()
1027        };
1028
1029        if let Some(existing) = cell.get()
1030            && existing.max_request_body != max_request_body
1031        {
1032            return Err(CamelError::EndpointCreationFailed(format!(
1033                "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1034                existing.max_request_body, max_request_body
1035            )));
1036        }
1037
1038        if let Some(existing) = cell.get()
1039            && existing.max_response_body != max_response_body
1040        {
1041            return Err(CamelError::EndpointCreationFailed(format!(
1042                "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1043                existing.max_response_body, max_response_body
1044            )));
1045        }
1046
1047        if let Some(existing) = cell.get()
1048            && existing.max_inflight_requests != max_inflight_requests
1049        {
1050            return Err(CamelError::EndpointCreationFailed(format!(
1051                "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1052                existing.max_inflight_requests, max_inflight_requests
1053            )));
1054        }
1055
1056        // TLS mode mismatch: plain vs TLS
1057        if let Some(existing) = cell.get()
1058            && existing.is_tls != tls_config.is_some()
1059        {
1060            return Err(CamelError::EndpointCreationFailed(format!(
1061                "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1062                existing.is_tls,
1063                tls_config.is_some()
1064            )));
1065        }
1066
1067        // TLS cert/key mismatch: different cert on same TLS port
1068        if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1069            && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1070                || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1071        {
1072            return Err(CamelError::EndpointCreationFailed(format!(
1073                "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1074            )));
1075        }
1076
1077        let handle = cell
1078            .get_or_try_init(|| {
1079                let rt = Arc::clone(&runtime);
1080                let rid = route_id.clone();
1081                let key = (host_owned.clone(), port);
1082                async move {
1083                    // Resolve the listener source inside the init body so
1084                    // exactly one caller — the init winner — consumes a
1085                    // staged listener. Resolving it before the cell init let
1086                    // a racing caller strand the staged socket in the
1087                    // loser's hands: the winner then bound the same port and
1088                    // failed with EADDRINUSE. The sync registry lock here is
1089                    // never held across an await. Occupied cells never run
1090                    // this body, so they never touch the staged map.
1091                    let source = match provided {
1092                        Some(listener) => ListenerSource::Staged(listener),
1093                        None => {
1094                            let mut guard = self.inner.lock().map_err(|_| {
1095                                CamelError::EndpointCreationFailed(
1096                                    "ServerRegistry lock poisoned".into(),
1097                                )
1098                            })?;
1099                            match guard.staged.remove(&key) {
1100                                Some(listener) => ListenerSource::Staged(listener),
1101                                // Conflict check before any entry is
1102                                // initialized so the error leaves the staged
1103                                // slot untouched.
1104                                None => {
1105                                    if let Some((staged_host, _)) = guard
1106                                        .staged
1107                                        .keys()
1108                                        .find(|(_, staged_port)| *staged_port == port)
1109                                    {
1110                                        let staged_host = staged_host.clone();
1111                                        return Err(CamelError::EndpointCreationFailed(
1112                                            format!(
1113                                                "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1114                                            ),
1115                                        ));
1116                                    }
1117                                    ListenerSource::Bind
1118                                }
1119                            }
1120                        }
1121                    };
1122                    spawn_entry(
1123                        key,
1124                        source,
1125                        max_request_body,
1126                        max_response_body,
1127                        max_inflight_requests,
1128                        rt,
1129                        rid,
1130                        tls_config,
1131                    )
1132                    .await
1133                    .and_then(|handle| {
1134                        // spawn_entry returns a freshly created Arc (refcount
1135                        // 1), so unwrapping it back into the owned handle for
1136                        // the cell always succeeds here.
1137                        Arc::try_unwrap(handle).map_err(|_| {
1138                            CamelError::EndpointCreationFailed(
1139                                "spawned server handle has dangling clones".into(),
1140                            )
1141                        })
1142                    })
1143                }
1144            })
1145            .await?;
1146
1147        Ok(handle.registry.clone())
1148    }
1149
1150    /// Unregister one consumer from a server. HTTP servers are process-lifetime:
1151    /// the server stays in the registry for potential restart. Path
1152    /// deregistration happens separately in the consumer's cleanup.
1153    pub async fn unregister(&self, host: &str, port: u16) {
1154        debug!(
1155            host = host,
1156            port = port,
1157            "consumer unregistered from HTTP server"
1158        );
1159    }
1160
1161    /// Reset the global registry — **test-only**.
1162    ///
1163    /// Clears all registered server handles so that tests can start from a clean
1164    /// state. This is intentionally `#[cfg(test)]` because the registry is a
1165    /// process-global singleton in production and resetting it would break
1166    /// running servers.
1167    #[cfg(test)]
1168    pub fn reset() {
1169        let instance = Self::global();
1170        let mut guard = instance
1171            .inner
1172            .lock()
1173            .expect("ServerRegistry lock poisoned during test reset");
1174        guard.entries.clear();
1175        guard.staged.clear();
1176    }
1177}
1178
1179/// Where a spawned server's listening socket comes from: a fresh bind on
1180/// `key`, or a listener pre-bound (staged or passed) by the caller.
1181enum ListenerSource {
1182    Bind,
1183    Staged(tokio::net::TcpListener),
1184}
1185
1186/// Create the server handle for a vacant registry entry: serve `key` via a
1187/// freshly bound or caller-provided listener. This is the OnceCell init body
1188/// of `get_or_spawn`, extracted so the legacy and staged entry points share
1189/// one spawn path.
1190#[allow(clippy::too_many_arguments)]
1191async fn spawn_entry(
1192    key: ServerKey,
1193    source: ListenerSource,
1194    max_request_body: usize,
1195    max_response_body: usize,
1196    max_inflight_requests: usize,
1197    runtime: Arc<dyn RuntimeObservability>,
1198    route_id: String,
1199    tls_config: Option<crate::config::ServerTlsConfig>,
1200) -> Result<Arc<ServerHandle>, CamelError> {
1201    let rt = Arc::clone(&runtime);
1202    let rid = route_id.clone();
1203    let (host_owned, port) = key;
1204    let listener = match source {
1205        ListenerSource::Bind => {
1206            let addr = format!("{host_owned}:{port}");
1207            tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1208                CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1209            })?
1210        }
1211        ListenerSource::Staged(listener) => listener,
1212    };
1213    let bound_addr = listener
1214        .local_addr()
1215        .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1216    let registry = HttpRouteRegistry::new();
1217    let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1218    // Constructed once in the TLS branch so they can be retained
1219    // on ServerHandle for the reload handler (Task 7).
1220    let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1221    let tls_source: Option<ServerTlsSource>;
1222    let server_task = if let Some(ref tls) = tls_config {
1223        let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1224        let source = ServerTlsSource {
1225            cert_path: std::path::PathBuf::from(&tls.cert_path),
1226            key_path: std::path::PathBuf::from(&tls.key_path),
1227            client_ca_path: None,
1228        };
1229        // Build the RustlsConfig once — clone() is cheap (Arc
1230        // internally) and shares the ArcSwap the reload handler
1231        // will mutate via reload_from_config().
1232        let rustls_cfg =
1233            axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1234        tls_rustls_cfg = Some(rustls_cfg.clone());
1235        tls_source = Some(source);
1236        // Convert tokio listener to std for axum-server
1237        let std_listener = listener.into_std().map_err(|e| {
1238            CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1239        })?;
1240        tokio::spawn(run_axum_server_tls(
1241            std_listener,
1242            rustls_cfg,
1243            registry.clone(),
1244            max_request_body,
1245            max_response_body,
1246            Arc::clone(&inflight),
1247            Arc::clone(&rt),
1248            rid.clone(),
1249        ))
1250    } else {
1251        tls_rustls_cfg = None;
1252        tls_source = None;
1253        tokio::spawn(run_axum_server(
1254            listener,
1255            registry.clone(),
1256            max_request_body,
1257            max_response_body,
1258            Arc::clone(&inflight),
1259            Arc::clone(&rt),
1260            rid.clone(),
1261        ))
1262    };
1263    let addr_for_monitor = format!("{host_owned}:{port}");
1264    let monitor_task = tokio::spawn(monitor_axum_task(
1265        server_task,
1266        addr_for_monitor,
1267        Arc::clone(&rt),
1268        rid,
1269    ));
1270    let handle = ServerHandle {
1271        registry,
1272        bound_addr,
1273        max_request_body,
1274        max_response_body,
1275        max_inflight_requests,
1276        is_tls: tls_config.is_some(),
1277        tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1278        tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1279        monitor_task,
1280        tls_config: tls_rustls_cfg,
1281        tls_source,
1282    };
1283    // Register reload handler (exactly-once: inside OnceCell init closure).
1284    // Note: HTTP servers are process-lifetime (no release/eviction path),
1285    // so handlers are never unregistered. If eviction is added later,
1286    // add TlsReloadRegistry::global().unregister() there.
1287    if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1288    {
1289        let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1290            tls_cfg.clone(),
1291            source.clone(),
1292            host_owned.clone(),
1293            port,
1294        ));
1295        camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1296    }
1297    Ok(Arc::new(handle))
1298}
1299
1300// ---------------------------------------------------------------------------
1301// Axum server
1302// ---------------------------------------------------------------------------
1303
1304use axum::{
1305    Router,
1306    body::Body as AxumBody,
1307    extract::{Request, State},
1308    http::{Response, StatusCode},
1309    response::IntoResponse,
1310};
1311
1312#[derive(Clone)]
1313pub(crate) struct AppState {
1314    registry: HttpRouteRegistry,
1315    max_request_body: usize,
1316    max_response_body: usize,
1317    inflight: Arc<tokio::sync::Semaphore>,
1318}
1319
1320/// Hard wall-clock limit for one inbound request on the consumer side
1321/// (audit 2026-08-31, F2-1). A slow-drip client otherwise holds an
1322/// `inflight` semaphore permit (and its connection) indefinitely, starving
1323/// the consumer into 503s. 30s matches the documented component default
1324/// timeouts. Applies to the whole dispatch; streaming bodies are additionally
1325/// protected by the byte cap in `dispatch_handler`.
1326const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1327
1328async fn run_axum_server(
1329    listener: tokio::net::TcpListener,
1330    registry: HttpRouteRegistry,
1331    max_request_body: usize,
1332    max_response_body: usize,
1333    inflight: Arc<tokio::sync::Semaphore>,
1334    runtime: Arc<dyn RuntimeObservability>,
1335    route_id: String,
1336) {
1337    let state = AppState {
1338        registry,
1339        max_request_body,
1340        max_response_body,
1341        inflight,
1342    };
1343    let app = Router::new()
1344        .fallback(dispatch_handler)
1345        .with_state(state)
1346        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1347            StatusCode::REQUEST_TIMEOUT,
1348            CONSUMER_REQUEST_TIMEOUT,
1349        ));
1350
1351    axum::serve(listener, app).await.unwrap_or_else(|e| {
1352        runtime
1353            .metrics()
1354            .increment_errors(&route_id, "e:http:accept");
1355        // log-policy: outside-contract
1356        tracing::error!(error = %e, "Axum server error");
1357    });
1358}
1359
1360#[allow(clippy::too_many_arguments)]
1361async fn run_axum_server_tls(
1362    listener: std::net::TcpListener,
1363    tls_cfg: axum_server::tls_rustls::RustlsConfig,
1364    registry: HttpRouteRegistry,
1365    max_request_body: usize,
1366    max_response_body: usize,
1367    inflight: Arc<tokio::sync::Semaphore>,
1368    runtime: Arc<dyn RuntimeObservability>,
1369    route_id: String,
1370) {
1371    let state = AppState {
1372        registry,
1373        max_request_body,
1374        max_response_body,
1375        inflight,
1376    };
1377    let app = Router::new()
1378        .fallback(dispatch_handler)
1379        .with_state(state)
1380        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1381            StatusCode::REQUEST_TIMEOUT,
1382            CONSUMER_REQUEST_TIMEOUT,
1383        ));
1384
1385    // RustlsConfig is now constructed once in get_or_spawn and retained on
1386    // ServerHandle so the reload handler can call reload_from_config() on it.
1387
1388    // axum-server 0.8: from_tcp_rustls is fallible (TLS acceptor setup).
1389    let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1390        Ok(server) => server,
1391        Err(e) => {
1392            runtime
1393                .metrics()
1394                .increment_errors(&route_id, "e:http:accept-tls");
1395            // log-policy: outside-contract
1396            tracing::error!(error = %e, "Axum TLS server setup error");
1397            return;
1398        }
1399    };
1400
1401    server
1402        .serve(app.into_make_service())
1403        .await
1404        .unwrap_or_else(|e| {
1405            runtime
1406                .metrics()
1407                .increment_errors(&route_id, "e:http:accept-tls");
1408            // log-policy: outside-contract
1409            tracing::error!(error = %e, "Axum TLS server error");
1410        });
1411}
1412
1413/// Monitors an Axum server task and emits a structured error event if it
1414/// exits unexpectedly.
1415///
1416/// # Limitations
1417/// The HTTP server is shared across all routes on a port. Full per-route
1418/// CrashNotification propagation is deferred — this provides observable
1419/// structured logging as a first guard.
1420async fn monitor_axum_task(
1421    handle: tokio::task::JoinHandle<()>,
1422    addr: String,
1423    runtime: Arc<dyn RuntimeObservability>,
1424    route_id: String,
1425) {
1426    match handle.await {
1427        Ok(()) => {
1428            // Clean exit (process shutdown or normal stop)
1429        }
1430        Err(join_err) => {
1431            runtime
1432                .metrics()
1433                .increment_errors(&route_id, "e:http:server-task-exited");
1434            // log-policy: outside-contract
1435            tracing::error!(
1436                addr = %addr,
1437                error = %join_err,
1438                "Axum server task exited unexpectedly — all routes on this port are now dead"
1439            );
1440        }
1441    }
1442}
1443
1444/// Load a rustls ServerConfig from PEM cert/key files.
1445/// Adapted from camel-ws lib.rs load_tls_config.
1446fn load_tls_config(
1447    cert_path: &str,
1448    key_path: &str,
1449) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1450    use std::fs::File;
1451    use std::io::BufReader;
1452
1453    let cert_file = File::open(cert_path)
1454        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1455    let key_file = File::open(key_path)
1456        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1457
1458    let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1459        .collect::<Result<Vec<_>, _>>()
1460        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1461
1462    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1463        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1464        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1465
1466    tokio_rustls::rustls::ServerConfig::builder()
1467        .with_no_client_auth()
1468        .with_single_cert(certs, key)
1469        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1470}
1471
1472async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1473    let path = req.uri().path().to_owned();
1474    let method = req.method().to_string();
1475
1476    // Dispatch precedence (spec §7.2 / ADR-0009):
1477    //   1. Exact API path match (legacy `http:` routes without httpMethod)
1478    //   2. Templated API path match (REST, method-aware, by specificity)
1479    //   3. Static mount longest-prefix
1480    //   4. SPA fallback
1481    //
1482    // Legacy exact runs first: it is a cheap HashMap get, and the two
1483    // registries are mutually exclusive per route — a legacy route carries
1484    // no `httpMethod` and lives only in `api_routes`, while a REST-lowered
1485    // route carries `httpMethod` and lives only in `rest_endpoints`. So an
1486    // exact hit can never shadow a REST route that should have matched,
1487    // and running exact-first honours the documented precedence (the prior
1488    // REST-first order let a templated `GET /api/{resource}` steal a
1489    // request meant for an exact `GET /api/users`). Intra-REST method
1490    // disambiguation is handled inside `match_endpoint`, not by this
1491    // ordering. Review C2.
1492    let api_sender = {
1493        let inner = state.registry.inner.read().await;
1494        inner.api_routes.get(&path).cloned()
1495    }; // lock released BEFORE any IO
1496
1497    let (rest_sender, path_params) = if api_sender.is_some() {
1498        // Exact legacy match won — skip the templated scan entirely.
1499        (None, Default::default())
1500    } else {
1501        let inner = state.registry.inner.read().await;
1502        match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1503            rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1504            rest_match::MatchOutcome::Ambiguous => {
1505                // Ambiguous registration should have been rejected at
1506                // lowering time (rest.rs). Reaching here means two
1507                // equal-specificity templates matched one request —
1508                // surface a loud error rather than a silent 404. Review C3.
1509                // log-policy: handler-owned
1510                tracing::warn!(
1511                    method = %method,
1512                    path = %path,
1513                    "ambiguous REST template match — returning 500"
1514                );
1515                return Response::builder()
1516                    .status(StatusCode::INTERNAL_SERVER_ERROR)
1517                    .body(AxumBody::from("Internal Server Error"))
1518                    .expect("infallible"); // allow-unwrap
1519            }
1520            rest_match::MatchOutcome::NotFound => (None, Default::default()),
1521        }
1522    }; // lock released BEFORE any IO
1523
1524    let sender = api_sender.or(rest_sender);
1525
1526    if let Some(sender) = sender {
1527        let query = req.uri().query().unwrap_or("").to_string();
1528        let headers = req.headers().clone();
1529
1530        // Check Content-Length against limit BEFORE opening the stream
1531        let content_length: Option<u64> = headers
1532            .get(http::header::CONTENT_LENGTH)
1533            .and_then(|v| v.to_str().ok())
1534            .and_then(|s| s.parse().ok());
1535
1536        if let Some(len) = content_length
1537            && len > state.max_request_body as u64
1538        {
1539            return Response::builder()
1540                .status(StatusCode::PAYLOAD_TOO_LARGE)
1541                .body(AxumBody::from("Request body exceeds configured limit"))
1542                .expect("infallible"); // allow-unwrap
1543        }
1544
1545        let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1546            Ok(permit) => permit,
1547            Err(_) => {
1548                return Response::builder()
1549                    .status(StatusCode::SERVICE_UNAVAILABLE)
1550                    .body(AxumBody::from("Service Unavailable"))
1551                    .expect("infallible"); // allow-unwrap
1552            }
1553        };
1554
1555        // Build StreamBody from Axum body WITHOUT materializing.
1556        // SECURITY (audit 2026-08-31, F2-1): the Content-Length pre-check above
1557        // cannot see chunked/no-length requests. Wrap the stream with a hard
1558        // byte cap so ANY downstream consumption fails closed once
1559        // max_request_body is exceeded — the cap travels with the body.
1560        let content_type = headers
1561            .get(http::header::CONTENT_TYPE)
1562            .and_then(|v| v.to_str().ok())
1563            .map(|s| s.to_string());
1564
1565        let data_stream: BodyDataStream = req.into_body().into_data_stream();
1566        let max_body = state.max_request_body;
1567        let mut seen: u64 = 0;
1568        let capped_stream =
1569            data_stream
1570                .map_err(|e| CamelError::Io(e.to_string()))
1571                .map(move |chunk| match chunk {
1572                    Ok(bytes) => {
1573                        seen = seen.saturating_add(bytes.len() as u64);
1574                        if seen > max_body as u64 {
1575                            Err(CamelError::ProcessorError(format!(
1576                                "Request body exceeds configured limit of {max_body} bytes"
1577                            )))
1578                        } else {
1579                            Ok(bytes)
1580                        }
1581                    }
1582                    Err(e) => Err(e),
1583                });
1584        let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1585
1586        let stream_body = StreamBody {
1587            stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1588            metadata: StreamMetadata {
1589                size_hint: content_length,
1590                content_type,
1591                origin: None,
1592            },
1593        };
1594
1595        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1596        let envelope = RequestEnvelope {
1597            method,
1598            path,
1599            query,
1600            headers,
1601            body: stream_body,
1602            path_params,
1603            reply_tx,
1604        };
1605
1606        if sender.send(envelope).await.is_err() {
1607            return Response::builder()
1608                .status(StatusCode::SERVICE_UNAVAILABLE)
1609                .body(AxumBody::from("Consumer unavailable"))
1610                .expect("infallible"); // allow-unwrap
1611        }
1612
1613        match reply_rx.await {
1614            Ok(reply) => {
1615                let reply = match reply.body {
1616                    HttpReplyBody::Bytes(b)
1617                        if exceeds_max_response_body(b.len(), state.max_response_body) =>
1618                    {
1619                        HttpReply {
1620                            status: 500,
1621                            headers: vec![],
1622                            body: HttpReplyBody::Bytes(bytes::Bytes::from(
1623                                "Response body exceeds configured limit",
1624                            )),
1625                        }
1626                    }
1627                    _ => reply,
1628                };
1629
1630                let status =
1631                    StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1632                let mut builder = Response::builder().status(status);
1633                for (k, v) in &reply.headers {
1634                    builder = builder.header(k.as_str(), v.as_str());
1635                }
1636                match reply.body {
1637                    HttpReplyBody::Bytes(b) => {
1638                        builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1639                            Response::builder()
1640                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1641                                .body(AxumBody::from("Invalid response headers from consumer"))
1642                                .expect("infallible") // allow-unwrap
1643                        })
1644                    }
1645                    HttpReplyBody::Stream(stream) => builder
1646                        .body(AxumBody::from_stream(stream))
1647                        .unwrap_or_else(|_| {
1648                            Response::builder()
1649                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1650                                .body(AxumBody::from("Invalid response headers from consumer"))
1651                                .expect("infallible") // allow-unwrap
1652                        }),
1653                }
1654            }
1655            Err(_) => Response::builder()
1656                .status(StatusCode::INTERNAL_SERVER_ERROR)
1657                .body(AxumBody::from("Pipeline error"))
1658                .expect("infallible"), // allow-unwrap
1659        }
1660    } else {
1661        // No API route matched — try static mounts
1662        static_dispatch::dispatch_static(&state, req, &path).await
1663    }
1664}
1665
1666fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1667    len > max
1668}
1669
1670fn title_case_header(name: &str) -> String {
1671    name.split('-')
1672        .map(|part| {
1673            let mut chars = part.chars();
1674            match chars.next() {
1675                None => String::new(),
1676                Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1677            }
1678        })
1679        .collect::<Vec<_>>()
1680        .join("-")
1681}
1682
1683// ---------------------------------------------------------------------------
1684// HttpConsumer
1685// ---------------------------------------------------------------------------
1686
1687/// Kernel authentication state captured from a route's [`SecurityContext`]
1688/// (`unify-transport-auth`, Task 2.9).
1689///
1690/// Same construction-order lifecycle as gRPC's `GrpcKernelAuth` (Task 2.1):
1691/// the compiled plan and the provider registry arrive via
1692/// `Consumer::set_security_context` before `start()` accepts requests. A
1693/// context lacking either piece keeps `kernel = None` — a plan without
1694/// providers can never mint a principal (fail-closed, never a silently
1695/// unauthenticated route: the controller's strict-mode dispatch check then
1696/// denies carrier-less Exchanges on non-Public plans).
1697pub(crate) struct HttpKernelAuth {
1698    pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1699    pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1700}
1701
1702impl HttpKernelAuth {
1703    /// Capture the kernel state from a route's security context.
1704    ///
1705    /// `None` unless both the compiled plan and the provider registry are
1706    /// present.
1707    pub(crate) fn from_security_context(
1708        ctx: &camel_component_api::SecurityContext,
1709    ) -> Option<Self> {
1710        Some(Self {
1711            plan: ctx.plan.clone()?,
1712            providers: ctx.providers.clone()?,
1713        })
1714    }
1715}
1716
1717/// Capacity for the per-route RequestEnvelope channel.
1718///
1719/// Each in-flight request holds exactly one `maxInflightRequests` semaphore
1720/// permit from before `send()` until its reply, so at most N envelopes can be
1721/// outstanding at any time. A buffer of N therefore can never fill before the
1722/// semaphore exhausts: dispatcher `send()` calls never park on a full buffer
1723/// and the semaphore stays the single, URI-configurable backpressure point.
1724/// A hardcoded smaller buffer would act as a second, hidden inflight cap
1725/// (rc-3y6j: 64 vs default 1024 permits).
1726///
1727/// `max(1)`: `maxInflightRequests=0` is a representable "reject everything"
1728/// configuration, but `tokio::sync::mpsc::channel(0)` panics — keep consumer
1729/// start panic-free (the empty semaphore still 503s every request).
1730fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1731    max_inflight_requests.max(1)
1732}
1733
1734pub struct HttpConsumer {
1735    config: HttpServerConfig,
1736    /// Runtime observability handle for ADR-0012 metrics and health calls.
1737    runtime: Arc<dyn RuntimeObservability>,
1738    /// Kernel authentication state (plan + providers), set via
1739    /// `set_security_context` before `start()` (Task 2.9). `None` for routes
1740    /// without route-level security (Public under the per-bind gate).
1741    kernel: Option<Arc<HttpKernelAuth>>,
1742}
1743
1744impl HttpConsumer {
1745    pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1746        Self {
1747            config,
1748            runtime,
1749            kernel: None,
1750        }
1751    }
1752}
1753
1754#[async_trait::async_trait]
1755impl Consumer for HttpConsumer {
1756    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1757        use camel_component_api::{Body, Exchange, Message};
1758
1759        let registry = ServerRegistry::global()
1760            .get_or_spawn(
1761                &self.config.host,
1762                self.config.port,
1763                self.config.max_request_body,
1764                self.config.max_response_body,
1765                self.config.max_inflight_requests,
1766                self.runtime.clone(),
1767                ctx.route_id().to_string(),
1768                self.config.tls_config.clone(),
1769            )
1770            .await?;
1771
1772        // Create channel for this path and register it. Capacity matches the
1773        // dispatcher's inflight semaphore (see envelope_channel_capacity) so
1774        // the channel can never become a second backpressure point.
1775        let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1776            envelope_channel_capacity(self.config.max_inflight_requests),
1777        );
1778        // When the from-URI carries `httpMethod=...` (REST-lowered
1779        // route), register the consumer as a method-aware REST endpoint
1780        // so the dispatcher can route by (method, path template).
1781        // Otherwise fall back to the legacy path-only api_routes
1782        // registry. The two registries never overlap for the same
1783        // route: each consumer registers in exactly one of them.
1784        if let Some(method) = self.config.method.clone() {
1785            let segments = rest_match::parse_path_template(&self.config.path);
1786            registry
1787                .register_rest_endpoint(method, segments, env_tx)
1788                .await;
1789        } else {
1790            registry
1791                .register_api_route(self.config.path.clone(), env_tx)
1792                .await;
1793        }
1794
1795        // rc-w1u9: Signal readiness AFTER (1) TcpListener::bind succeeded
1796        // (inside get_or_spawn above), (2) the axum server task was spawned,
1797        // and (3) this route's path/REST endpoint was registered. At this
1798        // point the listener is genuinely accepting connections and any
1799        // request to this route will be dispatched (not 404'd). The runtime
1800        // uses this signal to publish RouteStarted and to release
1801        // ctx.start() so external benchmarks can emit a reliable
1802        // listener-bound marker.
1803        ctx.mark_ready();
1804
1805        let path = self.config.path.clone();
1806        let registry_for_cleanup = registry.clone();
1807        let cancel_token = ctx.cancel_token();
1808        let kernel = self.kernel.clone();
1809        loop {
1810            tokio::select! {
1811                _ = ctx.cancelled() => {
1812                    break;
1813                }
1814                envelope = env_rx.recv() => {
1815                    let Some(envelope) = envelope else { break; };
1816
1817                    // Build Exchange from HTTP request
1818                    let mut msg = Message::default();
1819
1820                    // Set standard Camel HTTP headers
1821                    msg.set_header("CamelHttpMethod",
1822                        serde_json::Value::String(envelope.method.clone()));
1823                    msg.set_header("CamelHttpPath",
1824                        serde_json::Value::String(envelope.path.clone()));
1825                    msg.set_header("CamelHttpQuery",
1826                        serde_json::Value::String(envelope.query.clone()));
1827
1828                    // Set path-parameter headers from REST template
1829                    // match. Expert guidance E2: the consumer is
1830                    // responsible for translating the dispatcher's
1831                    // matched params into `CamelHttpPath_<param>`
1832                    // headers on the Exchange, matching the convention
1833                    // used by Camel HTTP for templated routes.
1834                    for (param_name, param_value) in &envelope.path_params {
1835                        msg.set_header(
1836                            format!("CamelHttpPath_{param_name}"),
1837                            serde_json::Value::String(param_value.clone()),
1838                        );
1839                    }
1840
1841                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
1842                    for (k, v) in &envelope.headers {
1843                        if let Ok(val_str) = v.to_str() {
1844                            msg.set_header(
1845                                title_case_header(k.as_str()),
1846                                serde_json::Value::String(val_str.to_string()),
1847                            );
1848                        }
1849                    }
1850
1851                    // Body: always arrives as Body::Stream (native streaming)
1852                    // Routes can call into_bytes() if they need to materialize
1853                    msg.body = Body::Stream(envelope.body);
1854
1855                    #[allow(unused_mut)]
1856                    let mut exchange = Exchange::new(msg);
1857
1858                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1859                    #[cfg(feature = "otel")]
1860                    {
1861                        let headers: HashMap<String, String> = envelope
1862                            .headers
1863                            .iter()
1864                            .filter_map(|(k, v)| {
1865                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1866                            })
1867                            .collect();
1868                        camel_otel::extract_into_exchange(&mut exchange, &headers);
1869                    }
1870
1871                    let reply_tx = envelope.reply_tx;
1872                    let sender = ctx.sender().clone();
1873                    let path_clone = path.clone();
1874                    let cancel = cancel_token.clone();
1875                    // Task 2.9 boundary-auth inputs: the raw header map and
1876                    // the request URI (path + query) feed kernel credential
1877                    // extraction inside the per-request task.
1878                    let auth_headers = envelope.headers.clone();
1879                    let auth_uri: http::Uri = {
1880                        let full = if envelope.query.is_empty() {
1881                            envelope.path.clone()
1882                        } else {
1883                            format!("{}?{}", envelope.path, envelope.query)
1884                        };
1885                        // A malformed path cannot become a valid `Uri`; the
1886                        // empty default then carries no credentials, so
1887                        // extraction finds nothing and authn fails closed.
1888                        full.parse().unwrap_or_default()
1889                    };
1890                    let kernel = kernel.clone();
1891
1892                    // Spawn a task to handle this request concurrently
1893                    //
1894                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
1895                    // true concurrent request processing. This change was introduced as part of the
1896                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
1897                    //
1898                    // Rationale:
1899                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
1900                    //    the consumer's main loop until the pipeline processing completes
1901                    // 2. This blocking would prevent multiple HTTP requests from being processed
1902                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
1903                    // 3. The channel would never have multiple exchanges buffered simultaneously,
1904                    //    defeating the purpose of pipeline-side concurrency
1905                    // 4. By spawning a task per request, we allow the consumer loop to continue
1906                    //    accepting new requests while existing ones are processed in the pipeline
1907                    //
1908                    // This approach effectively decouples request acceptance from pipeline processing,
1909                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
1910                    // by the pipeline when ConcurrencyModel::Concurrent is active.
1911                    tokio::spawn(async move {
1912                        // Check for cancellation before sending to pipeline.
1913                        // Returns 503 (Service Unavailable) instead of letting the request
1914                        // enter a shutting-down pipeline. This is a behavioral change from
1915                        // the pre-concurrency implementation where cancellation during
1916                        // processing would result in a 500 (Internal Server Error).
1917                        // 503 is more semantically correct: the server is temporarily
1918                        // unable to handle the request due to shutdown.
1919                        if cancel.is_cancelled() {
1920                            let _ = reply_tx.send(HttpReply {
1921                                status: 503,
1922                                headers: vec![],
1923                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1924                            });
1925                            return;
1926                        }
1927
1928                        // ADR-0061 Task 2.9: kernel authentication at the
1929                        // request boundary. A `Public` plan passes through
1930                        // with no extraction; any other mode extracts per
1931                        // the plan's sources, authenticates through the
1932                        // kernel, and installs the typed carrier BEFORE the
1933                        // pipeline runs. A denial renders in the HTTP idiom
1934                        // (401 via `pipeline_error_to_reply`) and the route
1935                        // body never sees the request.
1936                        if let Some(kernel) = kernel.as_ref()
1937                            && !matches!(
1938                                kernel.plan.access_mode,
1939                                camel_api::security_policy::AccessMode::Public
1940                            )
1941                        {
1942                            let principal = match camel_auth::extract_token_multi(
1943                                &auth_headers,
1944                                &auth_uri,
1945                                &kernel.plan.credential_sources,
1946                            ) {
1947                                Some(extracted) => {
1948                                    match camel_auth::kernel_authenticate(
1949                                        &kernel.plan,
1950                                        &kernel.providers,
1951                                        &extracted,
1952                                    )
1953                                    .await
1954                                    {
1955                                        Ok(principal) => principal,
1956                                        Err(e) => {
1957                                            // log-policy: handler-owned
1958                                            tracing::warn!(
1959                                                path = %path_clone,
1960                                                error = %e,
1961                                                "HTTP request authentication failed"
1962                                            );
1963                                            let _ = reply_tx.send(pipeline_error_to_reply(
1964                                                e,
1965                                                &path_clone,
1966                                            ));
1967                                            return;
1968                                        }
1969                                    }
1970                                }
1971                                None => {
1972                                    // log-policy: handler-owned
1973                                    tracing::warn!(
1974                                        path = %path_clone,
1975                                        "HTTP request rejected: no credential found in any source"
1976                                    );
1977                                    let _ = reply_tx.send(pipeline_error_to_reply(
1978                                        CamelError::Unauthenticated(
1979                                            "no credential found in any source".to_string(),
1980                                        ),
1981                                        &path_clone,
1982                                    ));
1983                                    return;
1984                                }
1985                            };
1986                            camel_auth::install_carrier(&mut exchange, &principal);
1987                        }
1988
1989                        // Send through pipeline and await result
1990                        let (tx, rx) = tokio::sync::oneshot::channel();
1991                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
1992                            exchange,
1993                            reply_tx: Some(tx),
1994                        };
1995
1996                        let result = match sender.send(envelope).await {
1997                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1998                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1999                        }
2000                        .and_then(|r| r);
2001
2002                        let reply = match result {
2003                            Ok(out) => {
2004                                let status = out
2005                                    .input
2006                                    .header("CamelHttpResponseCode")
2007                                    .and_then(|v| {
2008                                        let raw = v.as_u64()
2009                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2010                                        let code = raw as u16;
2011                                        (100..1000).contains(&code).then_some(code)
2012                                    })
2013                                    .unwrap_or(200);
2014
2015                                let user_content_type = out
2016                                    .input
2017                                    .header("Content-Type")
2018                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
2019
2020                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2021                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2022                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2023                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2024                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2025                                        v.to_string().into_bytes(),
2026                                    )), Some("application/json".to_string())),
2027                                    Body::Stream(s) => {
2028                                        let ct = s.metadata.content_type.clone();
2029                                        match s.stream.lock().await.take() {
2030                                            Some(stream) => (
2031                                                HttpReplyBody::Stream(stream),
2032                                                ct,
2033                                            ),
2034                                            None => {
2035                                                // log-policy: system-broken
2036                                                tracing::error!(
2037                                                    "Body::Stream already consumed before HTTP reply — returning 500"
2038                                                );
2039                                                let error_reply = HttpReply {
2040                                                    status: 500,
2041                                                    headers: vec![],
2042                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2043                                                };
2044                                                if reply_tx.send(error_reply).is_err() {
2045                                                    debug!("reply_tx dropped before error reply could be sent");
2046                                                }
2047                                                return;
2048                                            }
2049                                        }
2050                                    }
2051                                    // Empty and future variants produce an empty reply body.
2052                                    _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2053                                };
2054
2055                                let resp_headers = select_response_headers(
2056                                    &out.input.headers,
2057                                    user_content_type,
2058                                    inferred_content_type,
2059                                );
2060
2061                                HttpReply {
2062                                    status,
2063                                    headers: resp_headers,
2064                                    body: reply_body,
2065                                }
2066                            }
2067                            Err(e) => {
2068                                pipeline_error_to_reply(e, &path_clone)
2069                            }
2070                        };
2071
2072                        // Reply to Axum handler (ignore error if client disconnected)
2073                        let _ = reply_tx.send(reply);
2074                    });
2075                }
2076            }
2077        }
2078
2079        // Deregister this consumer. Mirror the registration choice:
2080        // REST-registered consumers remove their (method, path) endpoint
2081        // WITHOUT touching sibling verbs on the same template (review C1);
2082        // legacy consumers clean up api_routes.
2083        if let Some(method) = &self.config.method {
2084            registry_for_cleanup
2085                .unregister_rest_endpoint(method, &path)
2086                .await;
2087        } else {
2088            registry_for_cleanup.unregister_api_route(&path).await;
2089        }
2090
2091        // D-L10: decrement the shared server's refcount. When the last
2092        // consumer on this (host, port) leaves, the server + monitor tasks
2093        // are aborted and the registry entry is removed.
2094        ServerRegistry::global()
2095            .unregister(&self.config.host, self.config.port)
2096            .await;
2097
2098        Ok(())
2099    }
2100
2101    async fn stop(&mut self) -> Result<(), CamelError> {
2102        Ok(())
2103    }
2104
2105    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2106        camel_component_api::ConcurrencyModel::Concurrent { max: None }
2107    }
2108
2109    // rc-w1u9: HTTP consumer binds a TcpListener inside start() (via
2110    // ServerRegistry::get_or_spawn) and only THEN can it accept connections.
2111    // Opting into Explicit startup makes ctx.start() await the bind+register
2112    // completion so listeners fail fast on bind errors (previously a silent
2113    // background log) and external markers can reliably detect listener-bound
2114    // state.
2115    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2116        camel_component_api::ConsumerStartupMode::Explicit
2117    }
2118
2119    // Task 2.9: capture the kernel state (compiled plan + provider registry)
2120    // wired by the route controller before start(). See `HttpKernelAuth`.
2121    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2122        self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2123    }
2124}
2125
2126// ---------------------------------------------------------------------------
2127// HttpComponent / HttpsComponent
2128// ---------------------------------------------------------------------------
2129
2130pub struct HttpComponent {
2131    config: HttpConfig,
2132    pinned_cache: std::sync::Arc<PinnedClientCache>,
2133    client: reqwest::Client,
2134}
2135
2136#[cfg(test)]
2137thread_local! {
2138    static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2139}
2140
2141pub(crate) fn build_client(
2142    config: &HttpConfig,
2143    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2144) -> reqwest::Client {
2145    #[cfg(test)]
2146    BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2147
2148    let mut builder = reqwest::Client::builder()
2149        .no_proxy() // CRITICAL: env proxies bypass resolve_to_addrs
2150        .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2151        .pool_max_idle_per_host(config.pool_max_idle_per_host)
2152        .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2153
2154    // Redirects are always handled manually in the producer's send path
2155    // so that each hop can be SSRF-validated. reqwest's built-in redirect
2156    // policy is sync and cannot perform async DNS resolution or SSRF checks.
2157    builder = builder.redirect(reqwest::redirect::Policy::none());
2158
2159    if let Some((host, addrs)) = resolve_override {
2160        builder = builder.resolve_to_addrs(host, addrs);
2161    }
2162
2163    if let Some(tls) = &config.tls
2164        && tls.enabled
2165    {
2166        if tls.insecure || !tls.verify_peer {
2167            // log-policy: handler-owned
2168            tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2169            builder = builder.danger_accept_invalid_certs(true);
2170        }
2171
2172        if let Some(ca_path) = &tls.ca_cert_path {
2173            // Audit 2026-08-31, F2-7: a configured CA that fails to load must
2174            // never degrade silently to system roots. Loud warn (config error
2175            // class: fail-fast would break existing deployments relying on the
2176            // fallback; the warning is the operator signal).
2177            match std::fs::read(ca_path) {
2178                Ok(ca_bytes) => {
2179                    match reqwest::Certificate::from_pem(&ca_bytes)
2180                        .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2181                    {
2182                        Ok(ca_cert) => {
2183                            builder = builder.add_root_certificate(ca_cert);
2184                        }
2185                        Err(e) => {
2186                            // log-policy: handler-owned
2187                            tracing::warn!(
2188                                error = %e,
2189                                "configured CA certificate failed to parse — falling back to system roots"
2190                            );
2191                        }
2192                    }
2193                }
2194                Err(e) => {
2195                    // log-policy: handler-owned
2196                    tracing::warn!(
2197                        error = %e,
2198                        "configured CA certificate file unreadable — falling back to system roots"
2199                    );
2200                }
2201            }
2202        }
2203
2204        // mTLS identity: BOTH files must load and parse, or the identity is
2205        // absent. A partial failure previously meant silently downgrading to
2206        // non-mTLS — now loud.
2207        if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2208            match (std::fs::read(cert_path), std::fs::read(key_path)) {
2209                (Ok(cert_bytes), Ok(key_bytes)) => {
2210                    let mut identity_pem = cert_bytes;
2211                    identity_pem.extend_from_slice(&key_bytes);
2212                    match reqwest::Identity::from_pem(&identity_pem) {
2213                        Ok(identity) => {
2214                            builder = builder.identity(identity);
2215                        }
2216                        Err(e) => {
2217                            // log-policy: handler-owned
2218                            tracing::warn!(
2219                                error = %e,
2220                                "configured mTLS identity failed to parse — client certificate NOT used"
2221                            );
2222                        }
2223                    }
2224                }
2225                (cert_r, key_r) => {
2226                    // log-policy: handler-owned
2227                    tracing::warn!(
2228                        cert_ok = cert_r.is_ok(),
2229                        key_ok = key_r.is_ok(),
2230                        "configured mTLS cert/key file unreadable — client certificate NOT used"
2231                    );
2232                }
2233            }
2234        }
2235    }
2236
2237    builder
2238        .build()
2239        .expect("reqwest::Client::build() with valid config should not fail") // allow-unwrap
2240}
2241
2242#[cfg(test)]
2243pub(crate) fn build_client_call_count() -> u64 {
2244    BUILD_CLIENT_CALLS.with(|c| c.get())
2245}
2246
2247impl HttpComponent {
2248    pub fn new() -> Self {
2249        let config = HttpConfig::default();
2250        Self {
2251            client: build_client(&config, None),
2252            config,
2253            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2254                PINNED_CLIENT_TTL,
2255                PINNED_CLIENT_MAX_ENTRIES,
2256            )),
2257        }
2258    }
2259
2260    pub fn with_config(config: HttpConfig) -> Self {
2261        Self {
2262            client: build_client(&config, None),
2263            config,
2264            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2265                PINNED_CLIENT_TTL,
2266                PINNED_CLIENT_MAX_ENTRIES,
2267            )),
2268        }
2269    }
2270
2271    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2272        match config {
2273            Some(cfg) => Self::with_config(cfg),
2274            None => Self::new(),
2275        }
2276    }
2277}
2278
2279impl Default for HttpComponent {
2280    fn default() -> Self {
2281        Self::new()
2282    }
2283}
2284
2285impl Component for HttpComponent {
2286    fn scheme(&self) -> &str {
2287        "http"
2288    }
2289
2290    fn metadata(&self) -> ComponentMetadata {
2291        HttpEndpointConfig::metadata()
2292    }
2293
2294    fn create_endpoint(
2295        &self,
2296        uri: &str,
2297        ctx: &dyn camel_component_api::ComponentContext,
2298    ) -> Result<Box<dyn Endpoint>, CamelError> {
2299        self.config.validate()?;
2300        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2301        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2302        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2303            server_config.host.clone(),
2304            server_config.port,
2305        )));
2306        self.pinned_cache
2307            .wire(HttpComponentKind::Http, ctx.metrics());
2308        Ok(Box::new(HttpEndpoint {
2309            uri: uri.to_string(),
2310            config,
2311            server_config,
2312            client: self.client.clone(),
2313            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2314            http_config: self.config.clone(),
2315        }))
2316    }
2317}
2318
2319pub struct HttpsComponent {
2320    config: HttpConfig,
2321    pinned_cache: std::sync::Arc<PinnedClientCache>,
2322    client: reqwest::Client,
2323}
2324
2325impl HttpsComponent {
2326    pub fn new() -> Self {
2327        let config = HttpConfig::default();
2328        Self {
2329            client: build_client(&config, None),
2330            config,
2331            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2332                PINNED_CLIENT_TTL,
2333                PINNED_CLIENT_MAX_ENTRIES,
2334            )),
2335        }
2336    }
2337
2338    pub fn with_config(config: HttpConfig) -> Self {
2339        Self {
2340            client: build_client(&config, None),
2341            config,
2342            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2343                PINNED_CLIENT_TTL,
2344                PINNED_CLIENT_MAX_ENTRIES,
2345            )),
2346        }
2347    }
2348
2349    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2350        match config {
2351            Some(cfg) => Self::with_config(cfg),
2352            None => Self::new(),
2353        }
2354    }
2355}
2356
2357impl Default for HttpsComponent {
2358    fn default() -> Self {
2359        Self::new()
2360    }
2361}
2362
2363impl Component for HttpsComponent {
2364    fn scheme(&self) -> &str {
2365        "https"
2366    }
2367
2368    fn metadata(&self) -> ComponentMetadata {
2369        // HTTPS shares the same URI option surface and capabilities as HTTP.
2370        // Only the scheme and description differ.
2371        let mut meta = HttpEndpointConfig::metadata();
2372        meta.scheme = "https".to_string();
2373        meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2374        meta
2375    }
2376
2377    fn create_endpoint(
2378        &self,
2379        uri: &str,
2380        ctx: &dyn camel_component_api::ComponentContext,
2381    ) -> Result<Box<dyn Endpoint>, CamelError> {
2382        self.config.validate()?;
2383        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2384        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2385        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2386            server_config.host.clone(),
2387            server_config.port,
2388        )));
2389        self.pinned_cache
2390            .wire(HttpComponentKind::Https, ctx.metrics());
2391        Ok(Box::new(HttpEndpoint {
2392            uri: uri.to_string(),
2393            config,
2394            server_config,
2395            client: self.client.clone(),
2396            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2397            http_config: self.config.clone(),
2398        }))
2399    }
2400}
2401
2402// ---------------------------------------------------------------------------
2403// HttpEndpoint
2404// ---------------------------------------------------------------------------
2405
2406struct HttpEndpoint {
2407    uri: String,
2408    config: HttpEndpointConfig,
2409    server_config: HttpServerConfig,
2410    client: reqwest::Client,
2411    pinned_cache: std::sync::Arc<PinnedClientCache>,
2412    http_config: HttpConfig,
2413}
2414
2415impl Endpoint for HttpEndpoint {
2416    fn uri(&self) -> &str {
2417        &self.uri
2418    }
2419
2420    fn create_consumer(
2421        &self,
2422        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2423    ) -> Result<Box<dyn Consumer>, CamelError> {
2424        // Scheme/config consistency check (spec §5) — uses parsed scheme
2425        // from HttpServerConfig, not a fragile port-443 heuristic.
2426        let scheme_is_https = self.server_config.scheme == "https";
2427        let has_tls = self.server_config.tls_config.is_some();
2428
2429        if scheme_is_https && !has_tls {
2430            return Err(CamelError::EndpointCreationFailed(
2431                "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2432            ));
2433        }
2434        if !scheme_is_https && has_tls {
2435            return Err(CamelError::EndpointCreationFailed(
2436                "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2437            ));
2438        }
2439        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2440    }
2441
2442    fn create_producer(
2443        &self,
2444        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2445        _ctx: &ProducerContext,
2446    ) -> Result<BoxProcessor, CamelError> {
2447        let producer = HttpProducer {
2448            config: Arc::new(self.config.clone()),
2449            client: self.client.clone(),
2450            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2451            http_config: Arc::new(self.http_config.clone()),
2452            runtime: rt,
2453        };
2454        if let Some(ref provider) = self.config.token_provider {
2455            let layer = BearerTokenLayer::new(Arc::clone(provider));
2456            Ok(BoxProcessor::new(layer.layer(producer)))
2457        } else {
2458            Ok(BoxProcessor::new(producer))
2459        }
2460    }
2461}
2462
2463// ---------------------------------------------------------------------------
2464// HttpProducer
2465// ---------------------------------------------------------------------------
2466
2467#[derive(Clone)]
2468struct HttpProducer {
2469    config: Arc<HttpEndpointConfig>,
2470    client: reqwest::Client,
2471    pinned_cache: std::sync::Arc<PinnedClientCache>,
2472    http_config: Arc<HttpConfig>,
2473    /// Runtime observability handle powering the component-ops facade at
2474    /// the request boundary (`("http","request")`, dashboard-observability
2475    /// Task 4.3). The retained `e:http:accept*` labels are consumer-side
2476    /// (server accept loop) — different boundary, no collision with
2477    /// `e:http:request`.
2478    runtime: Arc<dyn RuntimeObservability>,
2479}
2480
2481impl HttpProducer {
2482    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2483        if let Some(ref method) = config.http_method {
2484            return method.to_uppercase();
2485        }
2486        if let Some(method) = exchange
2487            .input
2488            .header("CamelHttpMethod")
2489            .and_then(|v| v.as_str())
2490        {
2491            return method.to_uppercase();
2492        }
2493        if !exchange.input.body.is_empty() {
2494            return "POST".to_string();
2495        }
2496        "GET".to_string()
2497    }
2498
2499    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
2500        // bridgeEndpoint=true: exchange URL headers (CamelHttpUri,
2501        // CamelHttpPath, CamelHttpQuery) are ignored per Apache Camel
2502        // bridging semantics. The endpoint's own query still rides: the
2503        // same raw-preserving, consumed-option-filtered query as the
2504        // non-bridge path (bridgeEndpoint itself is a consumed option),
2505        // with programmatic query_params appending absent keys after the
2506        // raw base. This check MUST come before the CamelHttpUri override
2507        // so bridging wins over that header.
2508        if config.bridge_endpoint {
2509            let Some(query) = resolve_endpoint_query(config)? else {
2510                return Ok(config.base_url.clone());
2511            };
2512            // Validation only (rc-ph7z2): a malformed base still errors
2513            // through the redacted-diagnostic path below. The parsed value
2514            // is NEVER re-emitted — assembly is verbatim string
2515            // composition, authored bytes end-to-end: no WHATWG
2516            // normalization (dot-segment collapse, default-port strip,
2517            // scheme/host lowercasing), matching every other arm (Papal
2518            // Direction A).
2519            let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
2520                CamelError::ProcessorError(format!(
2521                    "invalid base URL '{}': {e}",
2522                    redact_url_for_diagnostics(&config.base_url)
2523                ))
2524            })?;
2525            let mut url = config.base_url.clone();
2526            url.push('?');
2527            url.push_str(&query);
2528            return Ok(url);
2529        }
2530
2531        if let Some(uri) = exchange
2532            .input
2533            .header("CamelHttpUri")
2534            .and_then(|v| v.as_str())
2535        {
2536            // Host fence (allowedUriHosts): opt-in, fail-closed. Evaluated
2537            // on the raw override before any path/query assembly; a
2538            // rejection renders the URL only through the diagnostics
2539            // redaction path (ADR-0051).
2540            if let Some(fence) = &config.allowed_uri_hosts
2541                && !uri_host_allowed(uri, fence)?
2542            {
2543                return Err(CamelError::ProcessorError(format!(
2544                    "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
2545                    redact_url_for_diagnostics(uri)
2546                )));
2547            }
2548            // The override replaces the base URL; its own query is the
2549            // higher-precedence source for composition (ADR-0071) — the
2550            // endpoint base query does not ride an override. Split at the
2551            // first `?` so CamelHttpPath applies to the path component
2552            // and the queries merge at pair level, never a second `?`
2553            // marker.
2554            let (base, override_query) = match uri.split_once('?') {
2555                Some((base, query)) => (base, Some(query)),
2556                None => (uri, None),
2557            };
2558            // Resolve-time span validation for the override URI's own query
2559            // (rc-m4xk1): a forbidden byte is a resolve error naming the
2560            // byte, never a verbatim ride that later surfaces as a reqwest
2561            // send error. Covers both downstream arms — the verbatim push
2562            // and merge_header_query, which validates only the header side.
2563            if let Some(query) = override_query {
2564                for (_key, span) in raw_query_pairs(query)? {
2565                    validate_raw_query_span(span)?;
2566                }
2567            }
2568            let mut url = base.to_string();
2569            if let Some(path) = exchange
2570                .input
2571                .header("CamelHttpPath")
2572                .and_then(|v| v.as_str())
2573            {
2574                if !url.ends_with('/') && !path.starts_with('/') {
2575                    url.push('/');
2576                }
2577                url.push_str(path);
2578            }
2579            if let Some(query) = exchange
2580                .input
2581                .header("CamelHttpQuery")
2582                .and_then(|v| v.as_str())
2583            {
2584                if let Some(merged) = merge_header_query(override_query, query)? {
2585                    url.push('?');
2586                    url.push_str(&merged);
2587                }
2588                return Ok(url);
2589            }
2590            if let Some(query) = override_query {
2591                url.push('?');
2592                url.push_str(query);
2593            }
2594            return Ok(url);
2595        }
2596
2597        let mut url = config.base_url.clone();
2598
2599        if let Some(path) = exchange
2600            .input
2601            .header("CamelHttpPath")
2602            .and_then(|v| v.as_str())
2603        {
2604            if !url.ends_with('/') && !path.starts_with('/') {
2605                url.push('/');
2606            }
2607            url.push_str(path);
2608        }
2609
2610        if let Some(query) = exchange
2611            .input
2612            .header("CamelHttpQuery")
2613            .and_then(|v| v.as_str())
2614        {
2615            // Compose: the endpoint query (raw-preserving,
2616            // consumed-option-filtered) comes first and wins collisions;
2617            // header pairs append verbatim for absent keys (ADR-0071).
2618            // An empty header leaves the endpoint query unchanged.
2619            if let Some(merged) =
2620                merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2621            {
2622                url.push('?');
2623                url.push_str(&merged);
2624            }
2625            return Ok(url);
2626        }
2627
2628        if let Some(query) = resolve_endpoint_query(config)? {
2629            url.push('?');
2630            url.push_str(&query);
2631        }
2632
2633        Ok(url)
2634    }
2635
2636    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2637        status >= range.0 && status <= range.1
2638    }
2639}
2640
2641/// One allowlist entry of the `CamelHttpUri` host fence (`allowedUriHosts`
2642/// endpoint option). DNS hosts are stored ASCII-lowercased; IPv6 literals
2643/// in bracketed canonical form (the `url` crate's host serialization). A
2644/// `port` of `None` is a host-only entry and permits any port.
2645#[derive(Clone, Debug, PartialEq, Eq)]
2646pub struct AllowedUriHost {
2647    /// Canonical host: lowercased DNS name or bracketed IPv6 literal.
2648    pub host: String,
2649    /// `Some` pins the entry to one effective port; `None` permits any.
2650    pub port: Option<u16>,
2651}
2652
2653/// Parse the `allowedUriHosts` option value: comma-separated `host` or
2654/// `host:port` entries. Bracketed IPv6 is supported (`[::1]:8443`, bare
2655/// `[::1]` host-only). Empty segments are dropped. Segments are parsed
2656/// through the `url` crate (with an `http://` scheme injected) so DNS
2657/// names are lowercased and ports range-checked; anything it rejects is a
2658/// malformed entry. A value yielding zero valid entries is also an error.
2659/// Both failure modes fail endpoint creation (fail-closed).
2660fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
2661    let mut entries = Vec::new();
2662    for segment in raw.split(',') {
2663        let segment = segment.trim();
2664        if segment.is_empty() {
2665            continue;
2666        }
2667        let parsed = url::Url::parse(&format!("http://{segment}"))
2668            .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
2669        // A segment carrying a path or userinfo is a typo'd entry — the
2670        // spec's "any other malformed entry" clause. Silently narrowing it
2671        // to its hostname would widen or skew the fence.
2672        if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
2673            return Err(invalid_allowed_uri_host_entry(segment));
2674        }
2675        let Some(host) = parsed.host_str() else {
2676            return Err(invalid_allowed_uri_host_entry(segment));
2677        };
2678        entries.push(AllowedUriHost {
2679            host: host.to_string(),
2680            port: parsed.port(),
2681        });
2682    }
2683    if entries.is_empty() {
2684        return Err(CamelError::InvalidUri(
2685            "allowedUriHosts declares no valid host entries".to_string(),
2686        ));
2687    }
2688    Ok(entries)
2689}
2690
2691fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
2692    CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
2693}
2694
2695/// Whether `url_str` matches the fence. Parse failure or a host-less URL
2696/// is fail-closed (`Ok(false)`). DNS hosts compare case-insensitively
2697/// (both sides are lowercased by the `url` crate); IPv6 compares in
2698/// bracketed canonical form. A host-only entry permits any port; a
2699/// `host:port` entry matches only the effective port — the explicit port
2700/// or the scheme default (443 for https, 80 for http).
2701pub(crate) fn uri_host_allowed(
2702    url_str: &str,
2703    fence: &[AllowedUriHost],
2704) -> Result<bool, CamelError> {
2705    let Ok(parsed) = url::Url::parse(url_str) else {
2706        return Ok(false);
2707    };
2708    let Some(host) = parsed.host_str() else {
2709        return Ok(false);
2710    };
2711    let effective_port = parsed.port().or(match parsed.scheme() {
2712        "https" => Some(443_u16),
2713        "http" => Some(80),
2714        _ => None,
2715    });
2716    Ok(fence.iter().any(|entry| {
2717        entry.host == host
2718            && match entry.port {
2719                None => true,
2720                Some(port) => effective_port == Some(port),
2721            }
2722    }))
2723}
2724
2725/// Serialize the outbound query for the endpoint base.
2726///
2727/// Authored raw pairs come first, byte-for-byte minus consumed option keys
2728/// (order, separators and authored escapes — including `RAW(...)` text —
2729/// preserved); then programmatic `query_params` entries whose key is absent
2730/// from the authored pairs, in declaration order with minimal RFC-3986
2731/// encoding (`%20`, never `+`). Authored keys always win — no duplication,
2732/// no override.
2733///
2734/// Returns `Ok(None)` when no query component is emitted: no pairs at all,
2735/// or a non-empty raw query whose every pair was consumed. A bare `?`
2736/// marker (`raw_query == Some("")`) always emits the query component.
2737fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
2738    let mut parts: Vec<String> = Vec::new();
2739    let mut authored_keys = std::collections::HashSet::new();
2740
2741    if let Some(raw) = config.raw_query.as_deref() {
2742        for (key, span) in raw_query_pairs(raw)? {
2743            authored_keys.insert(key.clone());
2744            if is_consumed_option(&key) {
2745                continue;
2746            }
2747            validate_raw_query_span(span)?;
2748            parts.push(span.to_string());
2749        }
2750    }
2751
2752    for (key, value) in &config.query_params {
2753        if !authored_keys.contains(key.as_str()) {
2754            parts.push(format!(
2755                "{}={}",
2756                encode_query_component(key),
2757                encode_query_component(value)
2758            ));
2759        }
2760    }
2761
2762    if parts.is_empty() && config.raw_query.as_deref() != Some("") {
2763        return Ok(None);
2764    }
2765    Ok(Some(parts.join("&")))
2766}
2767
2768/// Compose the outbound query when a `CamelHttpQuery` exchange header is
2769/// present (ADR-0071). `higher_precedence` — the endpoint query in the
2770/// base arm, the override URI's own query in the override arm — comes
2771/// first and wins any key collision; header pairs append verbatim for
2772/// absent keys only. An empty header leaves the higher-precedence query
2773/// unchanged (no additional `?` marker). Header spans are validated, not
2774/// re-encoded: a byte forbidden in a query component is a resolve error
2775/// naming the byte (Wave-A law).
2776fn merge_header_query(
2777    higher_precedence: Option<&str>,
2778    header_query: &str,
2779) -> Result<Option<String>, CamelError> {
2780    if header_query.is_empty() {
2781        return Ok(higher_precedence.map(str::to_string));
2782    }
2783    let mut parts: Vec<String> = Vec::new();
2784    let mut higher_keys = std::collections::HashSet::new();
2785    for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
2786        higher_keys.insert(key);
2787        parts.push(span.to_string());
2788    }
2789    for (key, span) in raw_query_pairs(header_query)? {
2790        validate_raw_query_span(span)?;
2791        if !higher_keys.contains(key.as_str()) {
2792            parts.push(span.to_string());
2793        }
2794    }
2795    if parts.is_empty() {
2796        return Ok(None);
2797    }
2798    Ok(Some(parts.join("&")))
2799}
2800
2801/// Bytes that may appear unescaped in a URI query component. RFC 3986
2802/// (`query = *( pchar / "/" / "?" )`) admits unreserved, sub-delims, `:`,
2803/// `@`, `/`, `?`, and `%` — with ONE deliberate exclusion from the RFC set:
2804/// the apostrophe (`'`, 0x27). reqwest's WHATWG URL parser re-encodes 0x27
2805/// to `%27` in the special-query percent-encode set (http/https), so an
2806/// authored apostrophe can never ride the wire verbatim; admitting it would
2807/// silently normalize authored bytes (rc-nmupb). Authors write `%27`
2808/// explicitly when they mean the byte on the wire. The WHATWG set's other
2809/// extras (`"`, `` ` ``, `<`, `>`) are already rejected here — they are not
2810/// RFC 3986 query-legal bytes, so no special exclusion is needed for them.
2811fn is_legal_query_byte(byte: u8) -> bool {
2812    matches!(byte,
2813        b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
2814        | b'-' | b'.' | b'_' | b'~'
2815        | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
2816        | b':' | b'@' | b'/' | b'?'
2817        | b'%')
2818}
2819
2820/// Reject an authored raw pair carrying a byte that is not legal in a query
2821/// component (e.g. literal space, `#`, non-ASCII). The serializer never
2822/// silently re-encodes operator-authored bytes: "byte-for-byte" is bounded
2823/// to wire-legal bytes, and the check fires before the resolved string
2824/// reaches any consumer (SSRF pre-check, diagnostics redaction).
2825fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
2826    for &byte in span.as_bytes() {
2827        if !is_legal_query_byte(byte) {
2828            return Err(CamelError::ProcessorError(format!(
2829                "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
2830            )));
2831        }
2832    }
2833    Ok(())
2834}
2835
2836/// Minimal RFC-3986 percent-encoding for one programmatic query component:
2837/// unreserved bytes pass through, every other byte encodes as uppercase
2838/// hex. A space encodes as `%20`, never `+`.
2839fn encode_query_component(component: &str) -> String {
2840    const HEX: &[u8; 16] = b"0123456789ABCDEF";
2841    let mut out = String::with_capacity(component.len());
2842    for &byte in component.as_bytes() {
2843        match byte {
2844            b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
2845                out.push(byte as char);
2846            }
2847            _ => {
2848                out.push('%');
2849                out.push(HEX[(byte >> 4) as usize] as char);
2850                out.push(HEX[(byte & 0x0f) as usize] as char);
2851            }
2852        }
2853    }
2854    out
2855}
2856
2857/// Mask `user:pass@` userinfo in a base-URL string for the
2858/// `HttpEndpointConfig` Debug surface (rc-dhkeo, ADR-0051
2859/// redact-by-construction): byte-preserving string surgery — a
2860/// `url::Url` roundtrip would WHATWG-normalize the rendered bytes. The
2861/// camel grammar path may carry userinfo-style bytes
2862/// (`http://user:pass@h/p`); they must never render in diagnostics.
2863/// Returns the input unchanged when the authority carries no `@`.
2864fn mask_base_url_userinfo(raw: &str) -> String {
2865    let Some(scheme_end) = raw.find("://") else {
2866        return raw.to_string();
2867    };
2868    let after_scheme = &raw[scheme_end + 3..];
2869    // The authority ends at the first path/query/fragment introducer.
2870    let authority_end = after_scheme
2871        .find(['/', '?', '#'])
2872        .unwrap_or(after_scheme.len());
2873    let authority = &after_scheme[..authority_end];
2874    // rfind: when multiple `@` ride the authority, mask through the last —
2875    // over-masking is safe, under-masking is not.
2876    let Some(at) = authority.rfind('@') else {
2877        return raw.to_string();
2878    };
2879    let mut out = String::with_capacity(raw.len());
2880    out.push_str(&raw[..scheme_end + 3]);
2881    out.push_str("***@");
2882    out.push_str(&authority[at + 1..]);
2883    out.push_str(&after_scheme[authority_end..]);
2884    out
2885}
2886
2887/// Redact credentials from a URL before it reaches logs or error values
2888/// (ADR-0051 redact-by-construction). Masks userinfo (`user:pass@`) and the
2889/// query string (which commonly carries API keys/tokens). Host and path stay
2890/// visible for diagnosability. Best-effort: on parse failure the raw string is
2891/// returned truncated to 256 chars (never a secret-bearing suffix).
2892pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
2893    const MAX_URL_LOG_LEN: usize = 256;
2894    match url::Url::parse(raw) {
2895        Ok(mut u) => {
2896            if !u.username().is_empty() || u.password().is_some() {
2897                let _ = u.set_username("***");
2898                let _ = u.set_password(None);
2899            }
2900            if u.query().is_some() {
2901                u.set_query(None);
2902                // Mark that a query was present without echoing it.
2903                let mut s = u.to_string();
2904                if let Some(stripped) = s.strip_suffix('?') {
2905                    s = stripped.to_string();
2906                }
2907                s.push_str("?[redacted]");
2908                if s.len() > MAX_URL_LOG_LEN {
2909                    s.truncate(MAX_URL_LOG_LEN);
2910                }
2911                return s;
2912            }
2913            let mut s = u.to_string();
2914            if s.len() > MAX_URL_LOG_LEN {
2915                s.truncate(MAX_URL_LOG_LEN);
2916            }
2917            s
2918        }
2919        Err(_) => {
2920            let mut s = raw.to_string();
2921            s.truncate(MAX_URL_LOG_LEN);
2922            s
2923        }
2924    }
2925}
2926
2927/// Maximum bytes of an upstream error response body embedded into
2928/// `CamelError::HttpOperationFailed`. The body is attacker-controllable (a
2929/// malicious or compromised upstream), so it is truncated and lossy-decoded to
2930/// bound log injection / DLQ payload size.
2931const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
2932
2933fn truncate_error_body(body: &[u8]) -> String {
2934    if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
2935        String::from_utf8_lossy(body).into_owned()
2936    } else {
2937        let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
2938        s.push_str("...[truncated]");
2939        s
2940    }
2941}
2942
2943impl HttpProducer {
2944    /// Whether the HTTP method is entity-enclosing (may carry a request
2945    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
2946    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
2947    /// §9.3.1/§9.3.2).
2948    fn is_entity_enclosing(method: &str) -> bool {
2949        matches!(method, "POST" | "PUT" | "PATCH")
2950    }
2951}
2952
2953impl Service<Exchange> for HttpProducer {
2954    type Response = Exchange;
2955    type Error = CamelError;
2956    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2957
2958    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2959        Poll::Ready(Ok(()))
2960    }
2961
2962    fn call(&mut self, exchange: Exchange) -> Self::Future {
2963        let config = self.config.clone();
2964        let shared_client = self.client.clone();
2965        let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
2966        let http_config = self.http_config.clone();
2967        let component_metrics = self.runtime.component_metrics();
2968
2969        Box::pin(async move {
2970            let mut exchange = exchange;
2971            let outcome = async {
2972                let method_str = HttpProducer::resolve_method(&exchange, &config);
2973                // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
2974                // and PATCH may carry a request body. Any other resolved method
2975                // drops the exchange body before the request is built (Apache
2976                // Camel `HttpMethods` parity).
2977                let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
2978                let url = HttpProducer::resolve_url(&exchange, &config)?;
2979
2980                // SECURITY: Validate URL for SSRF
2981                ssrf::validate_url_for_ssrf(&url, &config)?;
2982
2983                // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
2984                // (L-H2). When the URL uses a domain name and SSRF protection is active,
2985                // reuse the endpoint's cached DNS-pinned client for that validated
2986                // (host, addrs) pair — built once with resolve_to_addrs, then shared so
2987                // repeated requests keep one connection pool without re-resolving DNS.
2988                // Per-request SSRF validation and DNS pinning are unchanged. IP-literal
2989                // URLs use the endpoint's unpinned shared client.
2990                let resolved =
2991                    ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2992                let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2993                    pinned_cache
2994                        .get_or_build(host.as_str(), addrs, || {
2995                            build_client(&http_config, Some((host.as_str(), addrs)))
2996                        })
2997                        .await
2998                } else {
2999                    shared_client.clone()
3000                };
3001
3002                debug!(
3003                    correlation_id = %exchange.correlation_id(),
3004                    method = %method_str,
3005                    url = %redact_url_for_diagnostics(&url),
3006                    "HTTP request"
3007                );
3008
3009                let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3010                    CamelError::ProcessorError(format!(
3011                        "Invalid HTTP method '{}': {}",
3012                        method_str, e
3013                    ))
3014                })?;
3015
3016                // Collect headers for potential redirect replay
3017                let mut collected_headers: Vec<(
3018                    reqwest::header::HeaderName,
3019                    reqwest::header::HeaderValue,
3020                )> = Vec::new();
3021
3022                if let Some(user_agent) = &config.user_agent
3023                    && !config.bridge_endpoint
3024                {
3025                    match constructed_header("user-agent", user_agent) {
3026                        Ok((_, val)) => {
3027                            collected_headers.push((reqwest::header::USER_AGENT, val));
3028                        }
3029                        Err(drop) => debug!(
3030                            correlation_id = %exchange.correlation_id(),
3031                            header = %drop.name,
3032                            "outbound header dropped: {}",
3033                            drop.reason
3034                        ),
3035                    }
3036                }
3037
3038                // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
3039                #[cfg(feature = "otel")]
3040                let should_inject_otel = !config.bridge_endpoint;
3041                #[cfg(feature = "otel")]
3042                if should_inject_otel {
3043                    let mut otel_headers = HashMap::new();
3044                    camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3045                    for (k, v) in otel_headers {
3046                        match constructed_header(&k, &v) {
3047                            Ok((name, val)) => collected_headers.push((name, val)),
3048                            Err(drop) => debug!(
3049                                correlation_id = %exchange.correlation_id(),
3050                                header = %drop.name,
3051                                "outbound header dropped: {}",
3052                                drop.reason
3053                            ),
3054                        }
3055                    }
3056                }
3057
3058                let conn_tokens = header_policy::connection_tokens(
3059                    exchange
3060                        .input
3061                        .headers
3062                        .iter()
3063                        .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3064                        .filter_map(|(_, v)| v.as_str()),
3065                );
3066
3067                let outbound = select_outbound_headers(
3068                    &exchange.input.headers,
3069                    &config.skip_request_headers,
3070                    &conn_tokens,
3071                );
3072                for drop in &outbound.drops {
3073                    if let Some(value_kind) = drop.value_kind {
3074                        debug!(
3075                            correlation_id = %exchange.correlation_id(),
3076                            header = %drop.name,
3077                            value_kind = value_kind,
3078                            "outbound header dropped: {}",
3079                            drop.reason
3080                        );
3081                    } else {
3082                        debug!(
3083                            correlation_id = %exchange.correlation_id(),
3084                            header = %drop.name,
3085                            "outbound header dropped: {}",
3086                            drop.reason
3087                        );
3088                    }
3089                }
3090                collected_headers.extend(outbound.accepted);
3091
3092                // Auth headers
3093                if !config.bridge_endpoint {
3094                    match &config.auth {
3095                        HttpAuth::None => {}
3096                        HttpAuth::Basic { username, password } => {
3097                            use base64::Engine;
3098                            // allow-secret: credentials combined for base64 Basic auth header
3099                            let credentials = format!("{username}:{password}");
3100                            let encoded =
3101                                base64::engine::general_purpose::STANDARD.encode(credentials);
3102                            // Base64 output is always header-safe; the guard is kept
3103                            // for uniformity with Bearer.
3104                            match constructed_header("authorization", &format!("Basic {encoded}")) {
3105                                Ok((_, val)) => {
3106                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3107                                }
3108                                Err(drop) => debug!(
3109                                    correlation_id = %exchange.correlation_id(),
3110                                    header = %drop.name,
3111                                    "outbound header dropped: {}",
3112                                    drop.reason
3113                                ),
3114                            }
3115                        }
3116                        HttpAuth::Bearer { token } => {
3117                            // allow-secret: Bearer token in Authorization header
3118                            let bearer = format!("Bearer {token}");
3119                            match constructed_header("authorization", &bearer) {
3120                                Ok((_, val)) => {
3121                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3122                                }
3123                                Err(drop) => debug!(
3124                                    correlation_id = %exchange.correlation_id(),
3125                                    header = %drop.name,
3126                                    "outbound header dropped: {}",
3127                                    drop.reason
3128                                ),
3129                            }
3130                        }
3131                    }
3132
3133                    if config.connection_close {
3134                        collected_headers.push((
3135                            reqwest::header::CONNECTION,
3136                            reqwest::header::HeaderValue::from_static("close"),
3137                        ));
3138                    }
3139                }
3140
3141                // Materialize body
3142                let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3143                let materialized_body: Option<Vec<u8>> = if is_stream_body {
3144                    if suppress_body {
3145                        // A stream body dropped under a non-entity-enclosing
3146                        // method always warns (its emptiness is unknowable) and
3147                        // stays consumed (mem::take). The stream attach arm below
3148                        // still runs its outer flag check, but the inner `if let
3149                        // Body::Stream` re-match fails on the now-Empty body, so
3150                        // no stream is attached and no AlreadyConsumed error can
3151                        // fire.
3152                        std::mem::take(&mut exchange.input.body);
3153                        // log-policy: handler-owned
3154                        tracing::warn!(
3155                            correlation_id = %exchange.correlation_id(),
3156                            method = %method_str,
3157                            "dropping request body for non-entity-enclosing HTTP method"
3158                        );
3159                    }
3160                    None // Streams can't be replayed on redirect
3161                } else {
3162                    let body = std::mem::take(&mut exchange.input.body);
3163                    let bytes = body.into_bytes(config.max_body_size).await?;
3164                    if bytes.is_empty() {
3165                        // Empty body: nothing to send and nothing to warn about.
3166                        None
3167                    } else if suppress_body {
3168                        // log-policy: handler-owned
3169                        tracing::warn!(
3170                            correlation_id = %exchange.correlation_id(),
3171                            method = %method_str,
3172                            "dropping request body for non-entity-enclosing HTTP method"
3173                        );
3174                        None
3175                    } else {
3176                        Some(bytes.to_vec())
3177                    }
3178                };
3179
3180                let response = if config.follow_redirects && !is_stream_body {
3181                    // Use manual redirect loop with per-hop SSRF validation.
3182                    // `client` is the pinned-or-shared binding for the initial
3183                    // request (a hostname initial request keeps its DNS-pinned
3184                    // client); `shared_client` is the unpinned endpoint client
3185                    // reused by IP-literal redirect hops.
3186                    ssrf::send_with_ssrf_safe_redirects(
3187                        &client,
3188                        &shared_client,
3189                        &pinned_cache,
3190                        &http_config,
3191                        &config,
3192                        method,
3193                        &url,
3194                        collected_headers,
3195                        materialized_body,
3196                        config.max_redirects,
3197                        config.response_timeout,
3198                    )
3199                    .await?
3200                } else {
3201                    // Direct send (no redirect following, or streaming body)
3202                    let mut request = client.request(method, &url);
3203
3204                    if let Some(timeout) = config.response_timeout {
3205                        request = request.timeout(timeout);
3206                    }
3207
3208                    for (name, value) in &collected_headers {
3209                        request = request.header(name, value);
3210                    }
3211
3212                    if is_stream_body {
3213                        if let Body::Stream(ref s) = exchange.input.body {
3214                            let mut stream_lock = s.stream.lock().await;
3215                            if let Some(stream) = stream_lock.take() {
3216                                request = request.body(reqwest::Body::wrap_stream(stream));
3217                            } else {
3218                                return Err(CamelError::AlreadyConsumed);
3219                            }
3220                        }
3221                    } else if let Some(ref body_bytes) = materialized_body {
3222                        request = request.body(body_bytes.clone());
3223                    }
3224
3225                    request.send().await.map_err(|e| {
3226                        CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3227                    })?
3228                };
3229
3230                let status_code = response.status().as_u16();
3231                let status_text = response
3232                    .status()
3233                    .canonical_reason()
3234                    .unwrap_or("Unknown")
3235                    .to_string();
3236
3237                for (key, value) in response.headers() {
3238                    if config
3239                        .skip_response_headers
3240                        .iter()
3241                        .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3242                    {
3243                        continue;
3244                    }
3245                    if let Ok(val_str) = value.to_str() {
3246                        exchange.input.set_header(
3247                            title_case_header(key.as_str()),
3248                            serde_json::Value::String(val_str.to_string()),
3249                        );
3250                    }
3251                }
3252
3253                exchange.input.set_header(
3254                    "CamelHttpResponseCode",
3255                    serde_json::Value::Number(status_code.into()),
3256                );
3257                exchange.input.set_header(
3258                    "CamelHttpResponseText",
3259                    serde_json::Value::String(status_text.clone()),
3260                );
3261
3262                // Read response body with timeout and size guard (HTTP-004, HTTP-005)
3263                let read_timeout = Duration::from_millis(config.read_timeout_ms);
3264                let response_body = tokio::time::timeout(read_timeout, async {
3265                    // Check Content-Length header before allocating
3266                    if let Some(content_len) = response.content_length()
3267                        && content_len > config.max_response_bytes as u64
3268                    {
3269                        return Err(CamelError::ProcessorError(format!(
3270                            "Response body too large: {} bytes exceeds limit of {} bytes",
3271                            content_len, config.max_response_bytes
3272                        )));
3273                    }
3274                    // Use bytes_stream() for lazy streaming with size guard
3275                    use futures::TryStreamExt;
3276                    let mut stream = response.bytes_stream();
3277                    let mut total: usize = 0;
3278                    let mut collected = Vec::new();
3279                    while let Some(chunk) = stream.try_next().await.map_err(|e| {
3280                        CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3281                    })? {
3282                        total += chunk.len();
3283                        if total > config.max_response_bytes {
3284                            return Err(CamelError::ProcessorError(format!(
3285                                "Response body too large: {} bytes exceeds limit of {} bytes",
3286                                total, config.max_response_bytes
3287                            )));
3288                        }
3289                        collected.push(chunk);
3290                    }
3291                    let mut result = bytes::BytesMut::with_capacity(total);
3292                    for chunk in collected {
3293                        result.extend_from_slice(&chunk);
3294                    }
3295                    Ok::<bytes::Bytes, CamelError>(result.freeze())
3296                })
3297                .await
3298                .map_err(|_| {
3299                    CamelError::ProcessorError(format!(
3300                        "Read timeout after {}ms",
3301                        config.read_timeout_ms
3302                    ))
3303                })??;
3304
3305                if config.throw_exception_on_failure
3306                    && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3307                {
3308                    return Err(CamelError::HttpOperationFailed {
3309                        method: method_str,
3310                        // ADR-0051 redact-by-construction: never embed
3311                        // userinfo/query credentials in the error value.
3312                        url: redact_url_for_diagnostics(&url),
3313                        status_code,
3314                        status_text,
3315                        response_body: Some(truncate_error_body(&response_body)),
3316                    });
3317                }
3318
3319                if !response_body.is_empty() {
3320                    exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3321                }
3322
3323                debug!(
3324                    correlation_id = %exchange.correlation_id(),
3325                    status = status_code,
3326                    url = %redact_url_for_diagnostics(&url),
3327                    "HTTP response"
3328                );
3329                Ok(exchange)
3330            }
3331            .await;
3332            // ("http","request") facade (dashboard-observability 4.3): the
3333            // request boundary is the full client round-trip — SSRF checks,
3334            // send, response read, and (with throwExceptionOnFailure) the
3335            // status gate. http runs no retry_async and the producer
3336            // previously emitted nothing, so no label collides with
3337            // e:http:request.
3338            component_metrics.observe("http", "request", outcome.is_err());
3339            outcome
3340        })
3341    }
3342}
3343
3344/// Serializes tests that mutate or depend on the global `ServerRegistry`.
3345///
3346/// `ServerRegistry::global()` is a process-wide singleton that persists
3347/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
3348/// with another test that has a live server on a fixed port (e.g. 9991),
3349/// the registry entry is removed while the OS socket is still bound, so
3350/// the next `get_or_spawn` call on that port fails with "Address already
3351/// in use". Holding this mutex for the full body of each affected test
3352/// prevents the race without requiring `--test-threads=1`.
3353#[cfg(test)]
3354pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3355
3356/// Map a pipeline error to an HTTP reply.
3357///
3358/// Extracted from the inline `match` in `dispatch_handler` for unit
3359/// testability (rc-1dk4). Client-fault errors map to their 4xx codes
3360/// with a structured JSON error body: `TypeConversionFailed`/
3361/// `ValidationError` → 400, `UnsupportedMediaType` → 415 (media
3362/// negotiation gate, REST lowering), `NotAcceptable` → 406 (same
3363/// gate); `Unauthenticated`/`Unauthorized` keep their `401`/`403`
3364/// mappings; all other errors map to `500 Internal Server Error`.
3365fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3366    match e {
3367        CamelError::Unauthenticated(msg) => {
3368            tracing::warn!(error = %msg, path = %path, "Authentication failed");
3369            HttpReply {
3370                status: 401,
3371                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3372                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3373            }
3374        }
3375        CamelError::Unauthorized(msg) => {
3376            tracing::warn!(error = %msg, path = %path, "Authorization failed");
3377            HttpReply {
3378                status: 403,
3379                headers: vec![],
3380                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3381            }
3382        }
3383        CamelError::TypeConversionFailed(msg) => {
3384            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3385            let body = serde_json::to_string(&serde_json::json!({
3386                "error": "bad_request",
3387                "message": msg,
3388            }))
3389            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3390            HttpReply {
3391                status: 400,
3392                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3393                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3394            }
3395        }
3396        CamelError::ValidationError(msg) => {
3397            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3398            let body = serde_json::to_string(&serde_json::json!({
3399                "error": "validation_error",
3400                "message": msg,
3401            }))
3402            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3403            HttpReply {
3404                status: 400,
3405                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3406                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3407            }
3408        }
3409        CamelError::ConsumerStopping => {
3410            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3411            HttpReply {
3412                status: 503,
3413                headers: vec![],
3414                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3415            }
3416        }
3417        CamelError::UnsupportedMediaType { consumed, declared } => {
3418            tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3419            let body = serde_json::to_string(&serde_json::json!({
3420                "error": "unsupported_media_type",
3421                "message": format!("consumed {consumed}, declared {declared}"),
3422            }))
3423            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3424            HttpReply {
3425                status: 415,
3426                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3427                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3428            }
3429        }
3430        CamelError::NotAcceptable { accept, produced } => {
3431            tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3432            let body = serde_json::to_string(&serde_json::json!({
3433                "error": "not_acceptable",
3434                "message": format!("accept {accept}, produced {produced}"),
3435            }))
3436            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3437            HttpReply {
3438                status: 406,
3439                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3440                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3441            }
3442        }
3443        e => {
3444            // log-policy: handler-owned
3445            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3446            HttpReply {
3447                status: 500,
3448                headers: vec![],
3449                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3450            }
3451        }
3452    }
3453}
3454
3455/// Lowercase kind name for a JSON value, used in drop diagnostics so log
3456/// readers see *why* a header had no scalar string form without the value
3457/// itself ever entering diagnostics.
3458const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3459    match v {
3460        serde_json::Value::Null => "null",
3461        serde_json::Value::Bool(_) => "bool",
3462        serde_json::Value::Number(_) => "number",
3463        serde_json::Value::String(_) => "string",
3464        serde_json::Value::Array(_) => "array",
3465        serde_json::Value::Object(_) => "object",
3466    }
3467}
3468
3469/// Scalar string form of a JSON value: strings pass through, `Number` and
3470/// `Bool` are stringified, everything else has no single-value form.
3471/// Shared by the consumer reply finaliser and the producer outbound filter
3472/// so the two directions cannot drift apart (rc-lidtk / rc-8l23a).
3473fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3474    match v {
3475        serde_json::Value::String(s) => Some(s.clone()),
3476        serde_json::Value::Number(n) => Some(n.to_string()),
3477        serde_json::Value::Bool(b) => Some(b.to_string()),
3478        _ => None,
3479    }
3480}
3481
3482/// Select the HTTP response headers emitted by the consumer reply finaliser
3483/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
3484/// `dispatch_handler` for unit testability.
3485///
3486/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
3487/// server-owned headers, plus `content-length`/`content-type` (re-derived),
3488/// and any header named by a `Connection` token. Scalar non-string values
3489/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3490/// the wire instead of being silently discarded (rc-lidtk); `null`, objects,
3491/// and arrays have no single-value form and are dropped. Every drop is
3492/// logged at DEBUG with the header name and reason — names only, never
3493/// values, so credentials cannot leak into diagnostics (ADR-0051).
3494/// Appends a single `Content-Type` from `user_content_type` falling back to
3495/// `inferred_content_type` when either is present.
3496fn select_response_headers(
3497    headers: &HashMap<String, serde_json::Value>,
3498    user_content_type: Option<String>,
3499    inferred_content_type: Option<String>,
3500) -> Vec<(String, String)> {
3501    let conn_tokens = header_policy::connection_tokens(
3502        headers
3503            .iter()
3504            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3505            .filter_map(|(_, v)| v.as_str()),
3506    );
3507    let mut selected: Vec<(String, String)> = Vec::new();
3508    for (k, v) in headers {
3509        if k.starts_with("Camel") {
3510            debug!(header = %k, "reply header dropped: Camel namespace");
3511            continue;
3512        }
3513        if header_policy::excluded_response(k, &conn_tokens) {
3514            debug!(header = %k, "reply header dropped: emission policy");
3515            continue;
3516        }
3517        match scalar_string_form(v) {
3518            Some(s) => selected.push((k.clone(), s)),
3519            None => debug!(
3520                header = %k,
3521                value_kind = json_value_kind(v),
3522                "reply header dropped: no scalar string form"
3523            ),
3524        }
3525    }
3526    if let Some(ct) = user_content_type.or(inferred_content_type) {
3527        selected.push(("Content-Type".to_string(), ct));
3528    }
3529    selected
3530}
3531
3532/// One outbound header drop: the exchange header name, a stable reason
3533/// string, and — when the drop was caused by the value having no scalar
3534/// string form — the JSON value kind. Names and kinds only, never values
3535/// (ADR-0051).
3536#[derive(Debug)]
3537struct OutboundHeaderDrop<'a> {
3538    name: &'a str,
3539    reason: &'static str,
3540    value_kind: Option<&'static str>,
3541}
3542
3543/// Outbound exchange-header selection result: headers accepted for the
3544/// wire plus drop records for call-site DEBUG logging.
3545struct OutboundHeaderSelection<'a> {
3546    accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3547    drops: Vec<OutboundHeaderDrop<'a>>,
3548}
3549
3550/// Select the exchange headers the HTTP producer forwards on the outbound
3551/// request (ADR-0057 / rc-8l23a). Extracted from the inline filter in
3552/// `HttpProducer::call` for unit testability.
3553///
3554/// Drops `Camel`-namespace headers, names listed in `skip_request_headers`,
3555/// hop-by-hop/framing and connection-token-named headers excluded by the
3556/// outbound emission policy, and headers whose name or stringified value
3557/// fails `HeaderName`/`HeaderValue` construction. Scalar non-string values
3558/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3559/// the wire instead of being silently discarded (rc-8l23a); `null`, objects,
3560/// and arrays have no single-value form and are dropped. Drops are returned
3561/// rather than logged so the call site can attach the correlation id; log
3562/// consumers see names and kinds only, never values (ADR-0051).
3563fn select_outbound_headers<'a>(
3564    headers: &'a HashMap<String, serde_json::Value>,
3565    skip_request_headers: &[String],
3566    conn_tokens: &[String],
3567) -> OutboundHeaderSelection<'a> {
3568    let mut accepted = Vec::new();
3569    let mut drops = Vec::new();
3570    for (key, value) in headers {
3571        if key.starts_with("Camel") {
3572            drops.push(OutboundHeaderDrop {
3573                name: key,
3574                reason: "Camel namespace",
3575                value_kind: None,
3576            });
3577            continue;
3578        }
3579        if skip_request_headers
3580            .iter()
3581            .any(|h| h.eq_ignore_ascii_case(key))
3582        {
3583            drops.push(OutboundHeaderDrop {
3584                name: key,
3585                reason: "skip_request_headers",
3586                value_kind: None,
3587            });
3588            continue;
3589        }
3590        if header_policy::excluded_outbound(key, conn_tokens) {
3591            drops.push(OutboundHeaderDrop {
3592                name: key,
3593                reason: "outbound emission policy",
3594                value_kind: None,
3595            });
3596            continue;
3597        }
3598        let Some(val_str) = scalar_string_form(value) else {
3599            drops.push(OutboundHeaderDrop {
3600                name: key,
3601                reason: "no scalar string form",
3602                value_kind: Some(json_value_kind(value)),
3603            });
3604            continue;
3605        };
3606        match constructed_header(key, &val_str) {
3607            Ok((name, val)) => accepted.push((name, val)),
3608            Err(drop) => drops.push(drop),
3609        }
3610    }
3611    OutboundHeaderSelection { accepted, drops }
3612}
3613
3614/// Construct a wire-ready `(HeaderName, HeaderValue)` pair for one outbound
3615/// header, or a drop record when the name or value fails construction
3616/// (rc-jbs1v). Drop records carry name and reason only, never values
3617/// (ADR-0051).
3618fn constructed_header<'a>(
3619    name: &'a str,
3620    value: &str,
3621) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3622    let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3623        Ok(header_name) => header_name,
3624        Err(_) => {
3625            return Err(OutboundHeaderDrop {
3626                name,
3627                reason: "invalid header name",
3628                value_kind: None,
3629            });
3630        }
3631    };
3632    let header_value = match reqwest::header::HeaderValue::from_str(value) {
3633        Ok(header_value) => header_value,
3634        Err(_) => {
3635            return Err(OutboundHeaderDrop {
3636                name,
3637                reason: "invalid header value",
3638                value_kind: None,
3639            });
3640        }
3641    };
3642    Ok((header_name, header_value))
3643}
3644
3645#[cfg(test)]
3646mod tests {
3647    use camel_component_api::test_support::NoopRuntimeObservability;
3648
3649    // Producer/consumer tests drive the component-ops facade on every
3650    // call (dashboard-observability 4.3), so even non-observability tests
3651    // must supply a collector-returning runtime — Noop everywhere.
3652    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3653        std::sync::Arc::new(NoopRuntimeObservability)
3654    }
3655    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3656        std::sync::Arc::new(NoopRuntimeObservability)
3657    }
3658    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3659        std::sync::Arc::new(NoopRuntimeObservability)
3660    }
3661
3662    use super::*;
3663    use crate::rest_match::PathSegment;
3664    use camel_component_api::{Message, NoOpComponentContext};
3665    use std::sync::Arc;
3666    use std::time::Duration;
3667
3668    fn test_producer_ctx() -> ProducerContext {
3669        ProducerContext::new()
3670    }
3671
3672    // -----------------------------------------------------------------------
3673    // Security: credential redaction (audit 2026-08-31, finding F3-1)
3674    // -----------------------------------------------------------------------
3675
3676    #[test]
3677    fn redact_url_masks_userinfo_and_query() {
3678        let redacted =
3679            redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
3680        assert!(
3681            !redacted.contains("secretpass"),
3682            "password must be masked: {redacted}"
3683        );
3684        assert!(
3685            !redacted.contains("token=abc123"),
3686            "query must be masked: {redacted}"
3687        );
3688        assert!(
3689            !redacted.contains("user@"),
3690            "username must be masked: {redacted}"
3691        );
3692        assert!(
3693            redacted.contains("internal.example"),
3694            "host stays visible: {redacted}"
3695        );
3696        assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
3697    }
3698
3699    #[test]
3700    fn redact_url_keeps_clean_urls_visible() {
3701        let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
3702        assert_eq!(redacted, "https://api.example.com/v1/items");
3703    }
3704
3705    #[test]
3706    fn redact_url_masks_password_only_userinfo() {
3707        let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
3708        assert!(
3709            !redacted.contains("pwsecret"),
3710            "password-only userinfo leaked: {redacted}"
3711        );
3712        assert_eq!(redacted, "http://***@host.example/");
3713
3714        let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
3715        assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
3716        assert_eq!(redacted, "http://***@host.example/api");
3717
3718        let redacted = redact_url_for_diagnostics("http://host.example/api");
3719        assert_eq!(redacted, "http://host.example/api");
3720    }
3721
3722    #[test]
3723    fn redact_url_truncates_unparseable() {
3724        let long = "x".repeat(1000);
3725        let redacted = redact_url_for_diagnostics(&long);
3726        assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
3727    }
3728
3729    #[test]
3730    fn truncate_error_body_caps_attacker_body() {
3731        let big = vec![b'A'; 10 * 1024 * 1024];
3732        let truncated = truncate_error_body(&big);
3733        assert!(
3734            truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
3735            "body must be capped near {} bytes, got {}",
3736            MAX_ERROR_RESPONSE_BODY_BYTES,
3737            truncated.len()
3738        );
3739        assert!(truncated.ends_with("...[truncated]"));
3740    }
3741
3742    #[test]
3743    fn truncate_error_body_keeps_small_body() {
3744        assert_eq!(truncate_error_body(b"boom"), "boom");
3745    }
3746
3747    #[test]
3748    fn test_http_config_defaults() {
3749        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
3750        assert_eq!(config.base_url, "http://localhost:8080/api");
3751        assert!(config.http_method.is_none());
3752        assert!(config.throw_exception_on_failure);
3753        assert_eq!(config.ok_status_code_range, (200, 299));
3754        assert!(config.response_timeout.is_none());
3755        assert!(matches!(config.auth, HttpAuth::None));
3756        assert!(!config.bridge_endpoint);
3757        assert!(!config.connection_close);
3758    }
3759
3760    #[test]
3761    fn test_http_config_scheme() {
3762        // UriConfig trait method returns "http" as primary scheme
3763        assert_eq!(HttpEndpointConfig::scheme(), "http");
3764    }
3765
3766    #[test]
3767    fn test_http_config_from_components() {
3768        // Test from_components directly (trait method)
3769        let components = camel_component_api::UriComponents {
3770            scheme: "https".to_string(),
3771            path: "//api.example.com/v1".to_string(),
3772            params: std::collections::HashMap::from([(
3773                "httpMethod".to_string(),
3774                "POST".to_string(),
3775            )]),
3776            raw_query: None,
3777        };
3778        let config = HttpEndpointConfig::from_components(components).unwrap();
3779        assert_eq!(config.base_url, "https://api.example.com/v1");
3780        assert_eq!(config.http_method, Some("POST".to_string()));
3781    }
3782
3783    #[test]
3784    fn test_http_config_with_options() {
3785        let config = HttpEndpointConfig::from_uri(
3786            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
3787        ).unwrap();
3788        assert_eq!(config.base_url, "https://api.example.com/v1");
3789        assert_eq!(config.http_method, Some("PUT".to_string()));
3790        assert!(!config.throw_exception_on_failure);
3791        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
3792    }
3793
3794    #[test]
3795    fn test_http_endpoint_config_auth_and_headers_options() {
3796        let config = HttpEndpointConfig::from_uri(
3797            "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
3798        )
3799        .unwrap();
3800
3801        assert!(matches!(
3802            config.auth,
3803            HttpAuth::Basic { username, password } if username == "u" && password == "p"
3804        ));
3805        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
3806        assert!(config.bridge_endpoint);
3807        assert!(config.connection_close);
3808        assert_eq!(
3809            config.skip_request_headers,
3810            vec!["authorization".to_string(), "x-secret".to_string()]
3811        );
3812        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
3813    }
3814
3815    #[test]
3816    fn test_http_endpoint_config_bearer_auth() {
3817        let config = HttpEndpointConfig::from_uri(
3818            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
3819        )
3820        .unwrap();
3821        assert!(matches!(
3822            config.auth,
3823            HttpAuth::Bearer { token } if token == "t"
3824        ));
3825    }
3826
3827    #[test]
3828    fn rejects_cookie_handling_inmemory() {
3829        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
3830        match result {
3831            Err(CamelError::InvalidUri(msg)) => {
3832                assert!(
3833                    msg.contains("cookieHandling is not supported"),
3834                    "expected rejection message, got: {msg}"
3835                );
3836            }
3837            other => panic!("expected InvalidUri error, got: {other:?}"),
3838        }
3839    }
3840
3841    #[test]
3842    fn rejects_cookie_handling_disabled() {
3843        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
3844        match result {
3845            Err(CamelError::InvalidUri(msg)) => {
3846                assert!(
3847                    msg.contains("cookieHandling is not supported"),
3848                    "expected rejection message, got: {msg}"
3849                );
3850            }
3851            other => panic!("expected InvalidUri error, got: {other:?}"),
3852        }
3853    }
3854
3855    #[test]
3856    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
3857        let config = HttpConfig::default()
3858            .with_response_timeout_ms(999)
3859            .with_allow_internal(true)
3860            .with_blocked_hosts(vec!["evil.com".to_string()])
3861            .with_max_body_size(12345);
3862        let endpoint =
3863            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
3864        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
3865        assert!(endpoint.allow_internal);
3866        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
3867        assert_eq!(endpoint.max_body_size, 12345);
3868    }
3869
3870    #[test]
3871    fn test_from_uri_with_defaults_uri_overrides_config() {
3872        let config = HttpConfig::default()
3873            .with_response_timeout_ms(999)
3874            .with_allow_internal(true)
3875            .with_blocked_hosts(vec!["evil.com".to_string()])
3876            .with_max_body_size(12345);
3877        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
3878            "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
3879            &config,
3880        )
3881        .unwrap();
3882        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
3883        assert!(!endpoint.allow_internal);
3884        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
3885        assert_eq!(endpoint.max_body_size, 99);
3886    }
3887
3888    #[test]
3889    fn test_http_config_ok_status_range() {
3890        let config =
3891            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
3892        assert_eq!(config.ok_status_code_range, (200, 204));
3893    }
3894
3895    #[test]
3896    fn test_http_config_wrong_scheme() {
3897        let result = HttpEndpointConfig::from_uri("file:/tmp");
3898        assert!(result.is_err());
3899    }
3900
3901    #[test]
3902    fn test_http_component_scheme() {
3903        let component = HttpComponent::new();
3904        assert_eq!(component.scheme(), "http");
3905    }
3906
3907    #[test]
3908    fn test_https_component_scheme() {
3909        let component = HttpsComponent::new();
3910        assert_eq!(component.scheme(), "https");
3911    }
3912
3913    #[test]
3914    fn test_http_endpoint_creates_consumer() {
3915        let component = HttpComponent::new();
3916        let ctx = NoOpComponentContext;
3917        let endpoint = component
3918            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
3919            .unwrap();
3920        assert!(endpoint.create_consumer(rt()).is_ok());
3921    }
3922
3923    #[test]
3924    fn test_https_endpoint_creates_consumer_errors_without_tls() {
3925        let component = HttpsComponent::new();
3926        let ctx = NoOpComponentContext;
3927        let endpoint = component
3928            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
3929            .unwrap();
3930        // https:// without tlsCert/tlsKey must fail (scheme enforcement)
3931        assert!(endpoint.create_consumer(rt()).is_err());
3932    }
3933
3934    #[test]
3935    fn test_http_endpoint_creates_producer() {
3936        let ctx = test_producer_ctx();
3937        let component = HttpComponent::new();
3938        let endpoint_ctx = NoOpComponentContext;
3939        let endpoint = component
3940            .create_endpoint("http://localhost/api", &endpoint_ctx)
3941            .unwrap();
3942        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
3943    }
3944
3945    // -----------------------------------------------------------------------
3946    // Producer tests
3947    // -----------------------------------------------------------------------
3948
3949    #[tokio::test]
3950    async fn test_producer_with_token_provider() {
3951        use camel_auth::oauth2::TokenProvider;
3952        use tower::ServiceExt;
3953
3954        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
3955            Arc::new(std::sync::Mutex::new(None));
3956        let captured_clone = Arc::clone(&captured_auth);
3957
3958        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3959        let port = listener.local_addr().unwrap().port();
3960
3961        let _handle = tokio::spawn(async move {
3962            use tokio::io::{AsyncReadExt, AsyncWriteExt};
3963            if let Ok((mut stream, _)) = listener.accept().await {
3964                let mut buf = vec![0u8; 8192];
3965                let n = stream.read(&mut buf).await.unwrap_or(0);
3966                let request = String::from_utf8_lossy(&buf[..n]).to_string();
3967                let auth = request
3968                    .lines()
3969                    .find(|l| l.to_lowercase().starts_with("authorization:"))
3970                    .map(|l| {
3971                        l.split(':')
3972                            .nth(1)
3973                            .map(|s| s.trim().to_string())
3974                            .unwrap_or_default()
3975                    });
3976                *captured_clone.lock().unwrap() = auth;
3977                let body = r#"{"echo":"ok"}"#;
3978                let resp = format!(
3979                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3980                    body.len(),
3981                    body
3982                );
3983                let _ = stream.write_all(resp.as_bytes()).await;
3984            }
3985        });
3986
3987        #[derive(Debug)]
3988        struct StaticProvider;
3989        #[async_trait::async_trait]
3990        impl TokenProvider for StaticProvider {
3991            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
3992                Ok("injected-token".into())
3993            }
3994        }
3995
3996        let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
3997        let ctx = test_producer_ctx();
3998        let component = HttpComponent::new();
3999        let endpoint_ctx = NoOpComponentContext;
4000        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
4001        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4002
4003        let exchange = Exchange::new(Message::new("hello"));
4004
4005        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
4006        let mut layered = layer.layer(producer);
4007        let result = layered.ready().await.unwrap().call(exchange).await;
4008        assert!(result.is_ok(), "producer call failed: {:?}", result);
4009
4010        tokio::time::sleep(Duration::from_millis(100)).await;
4011        let auth = captured_auth.lock().unwrap().take();
4012        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
4013    }
4014
4015    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
4016        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4017        let addr = listener.local_addr().unwrap();
4018        let url = format!("http://127.0.0.1:{}", addr.port());
4019
4020        let handle = tokio::spawn(async move {
4021            loop {
4022                if let Ok((mut stream, _)) = listener.accept().await {
4023                    tokio::spawn(async move {
4024                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4025                        let mut buf = vec![0u8; 4096];
4026                        let n = stream.read(&mut buf).await.unwrap_or(0);
4027                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
4028
4029                        let method = request.split_whitespace().next().unwrap_or("GET");
4030
4031                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
4032                        let response = format!(
4033                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
4034                            body.len(),
4035                            body
4036                        );
4037                        let _ = stream.write_all(response.as_bytes()).await;
4038                    });
4039                }
4040            }
4041        });
4042
4043        (url, handle)
4044    }
4045
4046    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
4047        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4048        let addr = listener.local_addr().unwrap();
4049        let url = format!("http://127.0.0.1:{}", addr.port());
4050
4051        let handle = tokio::spawn(async move {
4052            loop {
4053                if let Ok((mut stream, _)) = listener.accept().await {
4054                    let status = status;
4055                    tokio::spawn(async move {
4056                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4057                        let mut buf = vec![0u8; 4096];
4058                        let _ = stream.read(&mut buf).await;
4059
4060                        let status_text = match status {
4061                            404 => "Not Found",
4062                            500 => "Internal Server Error",
4063                            _ => "Error",
4064                        };
4065                        let body = "error body";
4066                        let response = format!(
4067                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
4068                            status,
4069                            status_text,
4070                            body.len(),
4071                            body
4072                        );
4073                        let _ = stream.write_all(response.as_bytes()).await;
4074                    });
4075                }
4076            }
4077        });
4078
4079        (url, handle)
4080    }
4081
4082    async fn start_request_capturing_server() -> (
4083        String,
4084        Arc<std::sync::Mutex<Option<String>>>,
4085        tokio::task::JoinHandle<()>,
4086    ) {
4087        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4088        let port = listener.local_addr().unwrap().port();
4089        let url = format!("http://127.0.0.1:{port}");
4090        let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
4091        let captured_clone = Arc::clone(&captured);
4092        let handle = tokio::spawn(async move {
4093            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4094            if let Ok((mut stream, _)) = listener.accept().await {
4095                let mut buf = vec![0u8; 16384];
4096                let n = stream.read(&mut buf).await.unwrap_or(0);
4097                let request = String::from_utf8_lossy(&buf[..n]).to_string();
4098                if request.contains("\r\n\r\n") {
4099                    *captured_clone.lock().unwrap() = Some(request);
4100                }
4101                let body = r#"{"echo":"ok"}"#;
4102                let resp = format!(
4103                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4104                    body.len(),
4105                    body
4106                );
4107                let _ = stream.write_all(resp.as_bytes()).await;
4108            }
4109        });
4110        (url, captured, handle)
4111    }
4112
4113    #[tokio::test]
4114    async fn test_http_producer_get_request() {
4115        use tower::ServiceExt;
4116
4117        let (url, _handle) = start_test_server().await;
4118        let ctx = test_producer_ctx();
4119
4120        let component = HttpComponent::new();
4121        let endpoint_ctx = NoOpComponentContext;
4122        let endpoint = component
4123            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4124            .unwrap();
4125        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4126
4127        let exchange = Exchange::new(Message::default());
4128        let result = producer.oneshot(exchange).await.unwrap();
4129
4130        let status = result
4131            .input
4132            .header("CamelHttpResponseCode")
4133            .and_then(|v| v.as_u64())
4134            .unwrap();
4135        assert_eq!(status, 200);
4136
4137        assert!(!result.input.body.is_empty());
4138    }
4139
4140    #[tokio::test]
4141    async fn producer_excludes_host_and_framing() {
4142        use tower::ServiceExt;
4143
4144        let (url, captured, _handle) = start_request_capturing_server().await;
4145        let ctx = test_producer_ctx();
4146        let component = HttpComponent::new();
4147        let endpoint_ctx = NoOpComponentContext;
4148        let endpoint = component
4149            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4150            .unwrap();
4151        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4152
4153        let mut exchange = Exchange::new(Message::default());
4154        exchange.input.set_header("Host", "localhost");
4155        exchange.input.set_header("Content-Length", "42");
4156        exchange.input.set_header("Connection", "keep-alive");
4157        exchange.input.set_header("Upgrade", "h2c");
4158
4159        let result = producer.oneshot(exchange).await;
4160        assert!(result.is_ok(), "producer call failed: {:?}", result);
4161
4162        tokio::time::sleep(Duration::from_millis(100)).await;
4163        let request = captured
4164            .lock()
4165            .unwrap()
4166            .take()
4167            .expect("no outbound request captured");
4168        let lower = request.to_ascii_lowercase();
4169        assert!(
4170            !lower.contains("\r\nhost: localhost"),
4171            "forwarded Host: localhost must be stripped\n{request}"
4172        );
4173        assert!(
4174            !lower.contains("content-length: 42"),
4175            "exchange Content-Length must not be copied\n{request}"
4176        );
4177        assert!(
4178            !lower.lines().any(|l| l.starts_with("connection:")),
4179            "Connection header must not be forwarded\n{request}"
4180        );
4181        assert!(
4182            !lower.lines().any(|l| l.starts_with("upgrade:")),
4183            "Upgrade header must not be forwarded\n{request}"
4184        );
4185        let host_header = lower
4186            .lines()
4187            .find(|l| l.starts_with("host:"))
4188            .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
4189            .expect("outbound Host header must be set by reqwest");
4190        assert!(
4191            host_header.starts_with("127.0.0.1:"),
4192            "outbound Host '{host_header}' must match the capture-server address"
4193        );
4194    }
4195
4196    #[tokio::test]
4197    async fn producer_forwards_request_only_headers() {
4198        use tower::ServiceExt;
4199
4200        let (url, captured, _handle) = start_request_capturing_server().await;
4201        let ctx = test_producer_ctx();
4202        let component = HttpComponent::new();
4203        let endpoint_ctx = NoOpComponentContext;
4204        let endpoint = component
4205            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4206            .unwrap();
4207        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4208
4209        let mut exchange = Exchange::new(Message::default());
4210        exchange.input.set_header("Accept", "application/json");
4211        exchange.input.set_header("User-Agent", "myclient/1.0");
4212
4213        let result = producer.oneshot(exchange).await;
4214        assert!(result.is_ok(), "producer call failed: {:?}", result);
4215
4216        tokio::time::sleep(Duration::from_millis(100)).await;
4217        let request = captured
4218            .lock()
4219            .unwrap()
4220            .take()
4221            .expect("no outbound request captured");
4222        let lower = request.to_ascii_lowercase();
4223        assert!(
4224            lower.contains("accept: application/json"),
4225            "request-only Accept header must be forwarded\n{request}"
4226        );
4227        assert!(
4228            lower.contains("user-agent: myclient/1.0"),
4229            "request-only User-Agent header must be forwarded\n{request}"
4230        );
4231    }
4232
4233    // -----------------------------------------------------------------------
4234    // Configured-header construction failures are surfaced, never silent
4235    // (rc-jbs1v)
4236    // -----------------------------------------------------------------------
4237
4238    /// Build an endpoint whose URI parses normally but whose `user_agent`
4239    /// and `auth` are then overridden programmatically, so CRLF-bearing
4240    /// test values never pass through URI parsing.
4241    fn endpoint_with_config_overrides(
4242        base_url: &str,
4243        user_agent: Option<String>,
4244        auth: HttpAuth,
4245    ) -> HttpEndpoint {
4246        let uri = format!("{base_url}/api/test?allowInternal=true");
4247        let mut config =
4248            HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
4249        config.user_agent = user_agent;
4250        config.auth = auth;
4251        HttpEndpoint {
4252            uri: uri.clone(),
4253            config,
4254            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
4255            client: reqwest::Client::new(),
4256            pinned_cache: Arc::new(PinnedClientCache::new(
4257                PINNED_CLIENT_TTL,
4258                PINNED_CLIENT_MAX_ENTRIES,
4259            )),
4260            http_config: HttpConfig::default(),
4261        }
4262    }
4263
4264    /// A configured user-agent / bearer token that fails `HeaderValue`
4265    /// construction must be dropped with a DEBUG record (name + reason
4266    /// only, never the value — ADR-0051) and reach the wire absent, while
4267    /// a valid config passes through unchanged.
4268    #[tracing_test::traced_test]
4269    #[tokio::test]
4270    async fn producer_invalid_configured_headers_surfaced() {
4271        use tower::ServiceExt;
4272
4273        let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
4274        let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
4275        let ctx = test_producer_ctx();
4276
4277        let bad_producer = endpoint_with_config_overrides(
4278            &bad_url,
4279            Some("bad\r\nua".to_string()),
4280            HttpAuth::Bearer {
4281                token: "tok\r\nen".to_string(),
4282            },
4283        )
4284        .create_producer(rt(), &ctx)
4285        .unwrap();
4286        let ok_producer = endpoint_with_config_overrides(
4287            &ok_url,
4288            Some("httpsweep-ok/1".to_string()),
4289            HttpAuth::Bearer {
4290                token: "valid-token".to_string(),
4291            },
4292        )
4293        .create_producer(rt(), &ctx)
4294        .unwrap();
4295
4296        let bad_exchange = Exchange::new(Message::default());
4297        let ok_exchange = Exchange::new(Message::default());
4298        let bad_cid = bad_exchange.correlation_id().to_string();
4299        let ok_cid = ok_exchange.correlation_id().to_string();
4300
4301        let bad_result = bad_producer.oneshot(bad_exchange).await;
4302        assert!(
4303            bad_result.is_ok(),
4304            "invalid-config producer call failed: {bad_result:?}"
4305        );
4306        let ok_result = ok_producer.oneshot(ok_exchange).await;
4307        assert!(
4308            ok_result.is_ok(),
4309            "valid-config producer call failed: {ok_result:?}"
4310        );
4311
4312        tokio::time::sleep(Duration::from_millis(100)).await;
4313        let bad_request = bad_captured
4314            .lock()
4315            .unwrap()
4316            .take()
4317            .expect("no outbound request captured");
4318        let ok_request = ok_captured
4319            .lock()
4320            .unwrap()
4321            .take()
4322            .expect("no outbound request captured");
4323
4324        // Invalid config: neither header reaches the wire. Value-absence,
4325        // not "any UA" — reqwest may inject a default user-agent.
4326        let bad_lower = bad_request.to_ascii_lowercase();
4327        assert!(
4328            !bad_lower.lines().any(|l| l.starts_with("authorization:")),
4329            "invalid Bearer token must not reach the wire\n{bad_request}"
4330        );
4331        assert!(
4332            !bad_request.contains("bad\r\nua"),
4333            "invalid configured user-agent must not reach the wire\n{bad_request}"
4334        );
4335
4336        logs_assert(|lines: &[&str]| {
4337            let drops: Vec<&&str> = lines
4338                .iter()
4339                .filter(|l| {
4340                    l.contains("outbound header dropped")
4341                        && l.contains(&format!("correlation_id={bad_cid}"))
4342                })
4343                .collect();
4344            if drops.len() != 2 {
4345                return Err(format!(
4346                    "expected exactly 2 drop records for {bad_cid}, found {}",
4347                    drops.len()
4348                ));
4349            }
4350            let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
4351            let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
4352            let reason_ok = drops
4353                .iter()
4354                .all(|l| l.contains("outbound header dropped: invalid header value"));
4355            match (has_ua, has_auth, reason_ok) {
4356                (true, true, true) => Ok(()),
4357                _ => Err(format!(
4358                    "drop records mismatched: user-agent={has_ua} \
4359                     authorization={has_auth} reason-ok={reason_ok}"
4360                )),
4361            }
4362        });
4363        logs_assert(|lines: &[&str]| {
4364            if lines
4365                .iter()
4366                .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
4367            {
4368                Err("sentinel CRLF values leaked into logs".to_string())
4369            } else {
4370                Ok(())
4371            }
4372        });
4373
4374        // Valid config: both headers reach the wire exactly as configured,
4375        // with zero drop records.
4376        let ok_lower = ok_request.to_ascii_lowercase();
4377        assert!(
4378            ok_lower.contains("user-agent: httpsweep-ok/1"),
4379            "valid configured user-agent must reach the wire\n{ok_request}"
4380        );
4381        assert!(
4382            ok_lower.contains("authorization: bearer valid-token"),
4383            "valid Bearer token must reach the wire\n{ok_request}"
4384        );
4385        logs_assert(|lines: &[&str]| {
4386            let hits = lines
4387                .iter()
4388                .filter(|l| {
4389                    l.contains("outbound header dropped")
4390                        && l.contains(&format!("correlation_id={ok_cid}"))
4391                })
4392                .count();
4393            match hits {
4394                0 => Ok(()),
4395                n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
4396            }
4397        });
4398    }
4399
4400    #[tokio::test]
4401    async fn producer_honours_skip_request_headers() {
4402        use tower::ServiceExt;
4403
4404        let (url, captured, _handle) = start_request_capturing_server().await;
4405        let ctx = test_producer_ctx();
4406        let component = HttpComponent::new();
4407        let endpoint_ctx = NoOpComponentContext;
4408        let endpoint = component
4409            .create_endpoint(
4410                &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
4411                &endpoint_ctx,
4412            )
4413            .unwrap();
4414        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4415
4416        let mut exchange = Exchange::new(Message::default());
4417        exchange.input.set_header("Authorization", "Bearer x");
4418
4419        let result = producer.oneshot(exchange).await;
4420        assert!(result.is_ok(), "producer call failed: {:?}", result);
4421
4422        tokio::time::sleep(Duration::from_millis(100)).await;
4423        let request = captured
4424            .lock()
4425            .unwrap()
4426            .take()
4427            .expect("no outbound request captured");
4428        assert!(
4429            !request.to_ascii_lowercase().contains("authorization"),
4430            "Authorization must be stripped by skipRequestHeaders\n{request}"
4431        );
4432    }
4433
4434    #[tokio::test]
4435    async fn producer_stringifies_scalar_header_values_on_wire() {
4436        use tower::ServiceExt;
4437
4438        let (url, captured, _handle) = start_request_capturing_server().await;
4439        let ctx = test_producer_ctx();
4440        let component = HttpComponent::new();
4441        let endpoint_ctx = NoOpComponentContext;
4442        let endpoint = component
4443            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4444            .unwrap();
4445        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4446
4447        let mut exchange = Exchange::new(Message::default());
4448        exchange.input.set_header("X-Retries", serde_json::json!(3));
4449        exchange
4450            .input
4451            .set_header("X-Enabled", serde_json::json!(true));
4452        exchange
4453            .input
4454            .set_header("X-Obj", serde_json::json!({"a": 1}));
4455
4456        let result = producer.oneshot(exchange).await;
4457        assert!(result.is_ok(), "producer call failed: {:?}", result);
4458
4459        tokio::time::sleep(Duration::from_millis(100)).await;
4460        let request = captured
4461            .lock()
4462            .unwrap()
4463            .take()
4464            .expect("no outbound request captured");
4465        let lower = request.to_ascii_lowercase();
4466        assert!(
4467            lower.contains("x-retries: 3"),
4468            "numeric header must reach the wire stringified\n{request}"
4469        );
4470        assert!(
4471            lower.contains("x-enabled: true"),
4472            "bool header must reach the wire stringified\n{request}"
4473        );
4474        assert!(
4475            !lower.contains("x-obj:"),
4476            "object header has no single-value form and must not reach the wire\n{request}"
4477        );
4478    }
4479
4480    #[tokio::test]
4481    async fn test_http_producer_post_with_body() {
4482        use tower::ServiceExt;
4483
4484        let (url, _handle) = start_test_server().await;
4485        let ctx = test_producer_ctx();
4486
4487        let component = HttpComponent::new();
4488        let endpoint_ctx = NoOpComponentContext;
4489        let endpoint = component
4490            .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
4491            .unwrap();
4492        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4493
4494        let exchange = Exchange::new(Message::new("request body"));
4495        let result = producer.oneshot(exchange).await.unwrap();
4496
4497        let status = result
4498            .input
4499            .header("CamelHttpResponseCode")
4500            .and_then(|v| v.as_u64())
4501            .unwrap();
4502        assert_eq!(status, 200);
4503    }
4504
4505    #[tokio::test]
4506    async fn test_http_producer_method_from_header() {
4507        use tower::ServiceExt;
4508
4509        let (url, _handle) = start_test_server().await;
4510        let ctx = test_producer_ctx();
4511
4512        let component = HttpComponent::new();
4513        let endpoint_ctx = NoOpComponentContext;
4514        let endpoint = component
4515            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4516            .unwrap();
4517        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4518
4519        let mut exchange = Exchange::new(Message::default());
4520        exchange.input.set_header(
4521            "CamelHttpMethod",
4522            serde_json::Value::String("DELETE".to_string()),
4523        );
4524
4525        let result = producer.oneshot(exchange).await.unwrap();
4526        let status = result
4527            .input
4528            .header("CamelHttpResponseCode")
4529            .and_then(|v| v.as_u64())
4530            .unwrap();
4531        assert_eq!(status, 200);
4532    }
4533
4534    #[tokio::test]
4535    async fn test_http_producer_forced_method() {
4536        use tower::ServiceExt;
4537
4538        let (url, _handle) = start_test_server().await;
4539        let ctx = test_producer_ctx();
4540
4541        let component = HttpComponent::new();
4542        let endpoint_ctx = NoOpComponentContext;
4543        let endpoint = component
4544            .create_endpoint(
4545                &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
4546                &endpoint_ctx,
4547            )
4548            .unwrap();
4549        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4550
4551        let exchange = Exchange::new(Message::default());
4552        let result = producer.oneshot(exchange).await.unwrap();
4553
4554        let status = result
4555            .input
4556            .header("CamelHttpResponseCode")
4557            .and_then(|v| v.as_u64())
4558            .unwrap();
4559        assert_eq!(status, 200);
4560    }
4561
4562    #[tokio::test]
4563    async fn test_http_producer_throw_exception_on_failure() {
4564        use tower::ServiceExt;
4565
4566        let (url, _handle) = start_status_server(404).await;
4567        let ctx = test_producer_ctx();
4568
4569        let component = HttpComponent::new();
4570        let endpoint_ctx = NoOpComponentContext;
4571        let endpoint = component
4572            .create_endpoint(
4573                &format!("{url}/not-found?allowInternal=true"),
4574                &endpoint_ctx,
4575            )
4576            .unwrap();
4577        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4578
4579        let exchange = Exchange::new(Message::default());
4580        let result = producer.oneshot(exchange).await;
4581        assert!(result.is_err());
4582
4583        match result.unwrap_err() {
4584            CamelError::HttpOperationFailed { status_code, .. } => {
4585                assert_eq!(status_code, 404);
4586            }
4587            e => panic!("Expected HttpOperationFailed, got: {e}"),
4588        }
4589    }
4590
4591    #[tokio::test]
4592    async fn test_http_producer_no_throw_on_failure() {
4593        use tower::ServiceExt;
4594
4595        let (url, _handle) = start_status_server(500).await;
4596        let ctx = test_producer_ctx();
4597
4598        let component = HttpComponent::new();
4599        let endpoint_ctx = NoOpComponentContext;
4600        let endpoint = component
4601            .create_endpoint(
4602                &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
4603                &endpoint_ctx,
4604            )
4605            .unwrap();
4606        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4607
4608        let exchange = Exchange::new(Message::default());
4609        let result = producer.oneshot(exchange).await.unwrap();
4610
4611        let status = result
4612            .input
4613            .header("CamelHttpResponseCode")
4614            .and_then(|v| v.as_u64())
4615            .unwrap();
4616        assert_eq!(status, 500);
4617    }
4618
4619    #[tokio::test]
4620    async fn test_http_producer_uri_override() {
4621        use tower::ServiceExt;
4622
4623        let (url, _handle) = start_test_server().await;
4624        let ctx = test_producer_ctx();
4625
4626        let component = HttpComponent::new();
4627        let endpoint_ctx = NoOpComponentContext;
4628        let endpoint = component
4629            .create_endpoint(
4630                "http://localhost:1/does-not-exist?allowInternal=true",
4631                &endpoint_ctx,
4632            )
4633            .unwrap();
4634        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4635
4636        let mut exchange = Exchange::new(Message::default());
4637        exchange.input.set_header(
4638            "CamelHttpUri",
4639            serde_json::Value::String(format!("{url}/api")),
4640        );
4641
4642        let result = producer.oneshot(exchange).await.unwrap();
4643        let status = result
4644            .input
4645            .header("CamelHttpResponseCode")
4646            .and_then(|v| v.as_u64())
4647            .unwrap();
4648        assert_eq!(status, 200);
4649    }
4650
4651    #[tokio::test]
4652    async fn test_http_producer_response_headers_mapped() {
4653        use tower::ServiceExt;
4654
4655        let (url, _handle) = start_test_server().await;
4656        let ctx = test_producer_ctx();
4657
4658        let component = HttpComponent::new();
4659        let endpoint_ctx = NoOpComponentContext;
4660        let endpoint = component
4661            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4662            .unwrap();
4663        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4664
4665        let exchange = Exchange::new(Message::default());
4666        let result = producer.oneshot(exchange).await.unwrap();
4667
4668        assert!(
4669            result.input.header("Content-Type").is_some(),
4670            "Response should have Content-Type header"
4671        );
4672        assert!(result.input.header("CamelHttpResponseText").is_some());
4673    }
4674
4675    // -----------------------------------------------------------------------
4676    // Bug fix tests: Client configuration per-endpoint
4677    // -----------------------------------------------------------------------
4678
4679    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
4680        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4681        let addr = listener.local_addr().unwrap();
4682        let url = format!("http://127.0.0.1:{}", addr.port());
4683
4684        let handle = tokio::spawn(async move {
4685            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4686            loop {
4687                if let Ok((mut stream, _)) = listener.accept().await {
4688                    tokio::spawn(async move {
4689                        let mut buf = vec![0u8; 4096];
4690                        let n = stream.read(&mut buf).await.unwrap_or(0);
4691                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
4692
4693                        // Check if this is a request to /final
4694                        if request.contains("GET /final") {
4695                            let body = r#"{"status":"final"}"#;
4696                            let response = format!(
4697                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4698                                body.len(),
4699                                body
4700                            );
4701                            let _ = stream.write_all(response.as_bytes()).await;
4702                        } else {
4703                            // Redirect to /final
4704                            // Connection: close stops the client pooling the
4705                            // connection the server drops right after this
4706                            // response (pooled-race, rc-u3aw class).
4707                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
4708                            let _ = stream.write_all(response.as_bytes()).await;
4709                        }
4710                    });
4711                }
4712            }
4713        });
4714
4715        (url, handle)
4716    }
4717
4718    struct CapturedRequest {
4719        method: String,
4720        path: String,
4721        body: Vec<u8>,
4722        content_length: Option<String>,
4723        transfer_encoding: Option<String>,
4724    }
4725
4726    /// Parse a request head plus its Content-Length-driven body from a freshly
4727    /// accepted connection. Returns `None` if the client closes before sending
4728    /// a complete request head. Does NOT read until EOF/shutdown (reqwest pools
4729    /// keep-alive connections and never sends FIN) and does NOT rely on a
4730    /// single fixed-size read (a segmented small body would flake).
4731    async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
4732        use tokio::io::AsyncReadExt;
4733
4734        // Read the request head (up to and including the terminating CRLF CRLF).
4735        let mut buf: Vec<u8> = Vec::new();
4736        let mut chunk = [0u8; 4096];
4737        let head_end: usize;
4738        loop {
4739            let n = stream.read(&mut chunk).await.unwrap_or(0);
4740            if n == 0 {
4741                return None;
4742            }
4743            buf.extend_from_slice(&chunk[..n]);
4744            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
4745                head_end = pos + 4;
4746                break;
4747            }
4748        }
4749
4750        // Parse the request head.
4751        let head = String::from_utf8_lossy(&buf[..head_end]);
4752        let mut lines = head.split("\r\n");
4753        let request_line = lines.next().unwrap_or("");
4754        let mut parts = request_line.split_whitespace();
4755        let method = parts.next().unwrap_or("").to_string();
4756        let path = parts.next().unwrap_or("").to_string();
4757
4758        let mut content_length: Option<String> = None;
4759        let mut transfer_encoding: Option<String> = None;
4760        for line in lines {
4761            if let Some((name, value)) = line.split_once(':') {
4762                let name = name.trim().to_ascii_lowercase();
4763                let value = value.trim().to_string();
4764                if name == "content-length" {
4765                    content_length = Some(value);
4766                } else if name == "transfer-encoding" {
4767                    transfer_encoding = Some(value);
4768                }
4769            }
4770        }
4771
4772        // Content-Length-driven exact read. A missing header means a 0-length body.
4773        let body_len: usize = content_length
4774            .as_deref()
4775            .and_then(|v| v.parse::<usize>().ok())
4776            .unwrap_or(0);
4777
4778        let mut body: Vec<u8> = buf[head_end..].to_vec();
4779        while body.len() < body_len {
4780            let n = stream.read(&mut chunk).await.unwrap_or(0);
4781            if n == 0 {
4782                break;
4783            }
4784            body.extend_from_slice(&chunk[..n]);
4785        }
4786        body.truncate(body_len);
4787
4788        Some(CapturedRequest {
4789            method,
4790            path,
4791            body,
4792            content_length,
4793            transfer_encoding,
4794        })
4795    }
4796
4797    /// A raw-TCP capture server. Each connection parses the request head, then
4798    /// performs a Content-Length-driven exact read of the body (see
4799    /// [`capture_request`]). Each connection is dropped after the response so
4800    /// every hop opens a fresh connection.
4801    async fn start_capture_server() -> (
4802        String,
4803        tokio::task::JoinHandle<()>,
4804        Arc<Mutex<Vec<CapturedRequest>>>,
4805    ) {
4806        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4807        let addr = listener.local_addr().unwrap();
4808        let url = format!("http://127.0.0.1:{}", addr.port());
4809
4810        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4811        let captured_for_return = Arc::clone(&captured);
4812
4813        let handle = tokio::spawn(async move {
4814            use tokio::io::AsyncWriteExt;
4815            loop {
4816                if let Ok((mut stream, _)) = listener.accept().await {
4817                    let captured = Arc::clone(&captured);
4818                    tokio::spawn(async move {
4819                        let Some(req) = capture_request(&mut stream).await else {
4820                            return;
4821                        };
4822                        captured.lock().unwrap().push(req);
4823
4824                        // 200 OK with Content-Length: 0 and no body, then drop
4825                        // the stream so the client opens a fresh connection.
4826                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
4827                        let _ = stream.write_all(response.as_bytes()).await;
4828                    });
4829                }
4830            }
4831        });
4832
4833        (url, handle, captured_for_return)
4834    }
4835
4836    /// A raw-TCP capture server whose `/hop307` and `/hop308` paths answer with
4837    /// `307 Temporary Redirect` / `308 Permanent Redirect` to `/final`, and
4838    /// whose `/final` path answers `200 OK` with an empty body. Every hop
4839    /// records a `CapturedRequest` (Content-Length-driven exact read) and drops
4840    /// the connection after responding so each hop is a fresh connection.
4841    async fn start_redirect_capture_server() -> (
4842        String,
4843        tokio::task::JoinHandle<()>,
4844        Arc<Mutex<Vec<CapturedRequest>>>,
4845    ) {
4846        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4847        let addr = listener.local_addr().unwrap();
4848        let url = format!("http://127.0.0.1:{}", addr.port());
4849
4850        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4851        let captured_for_return = Arc::clone(&captured);
4852
4853        let handle = tokio::spawn(async move {
4854            use tokio::io::AsyncWriteExt;
4855            loop {
4856                if let Ok((mut stream, _)) = listener.accept().await {
4857                    let captured = Arc::clone(&captured);
4858                    tokio::spawn(async move {
4859                        let Some(req) = capture_request(&mut stream).await else {
4860                            return;
4861                        };
4862                        let path = req.path.clone();
4863                        captured.lock().unwrap().push(req);
4864
4865                        let (status_line, location) = match path.as_str() {
4866                            "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
4867                            "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
4868                            "/final" => ("HTTP/1.1 200 OK", None),
4869                            _ => ("HTTP/1.1 404 Not Found", None),
4870                        };
4871
4872                        let response = match location {
4873                            // Connection: close stops the client pooling the
4874                            // connection this handler drops right after the
4875                            // response (pooled-race, rc-u3aw class).
4876                            Some(loc) => format!(
4877                                "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
4878                            ),
4879                            None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
4880                        };
4881                        let _ = stream.write_all(response.as_bytes()).await;
4882                    });
4883                }
4884            }
4885        });
4886
4887        (url, handle, captured_for_return)
4888    }
4889
4890    #[tokio::test]
4891    async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
4892        use tower::ServiceExt;
4893
4894        let (url, _handle, captured) = start_capture_server().await;
4895        let ctx = test_producer_ctx();
4896
4897        let component = HttpComponent::with_config(HttpConfig::default());
4898        let endpoint_ctx = NoOpComponentContext;
4899        let endpoint = component
4900            .create_endpoint(
4901                &format!("{url}?httpMethod=GET&allowInternal=true"),
4902                &endpoint_ctx,
4903            )
4904            .unwrap();
4905        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4906
4907        let mut exchange = Exchange::new(Message::default());
4908        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4909
4910        let result = producer.oneshot(exchange).await.unwrap();
4911
4912        let status = result
4913            .input
4914            .header("CamelHttpResponseCode")
4915            .and_then(|v| v.as_u64())
4916            .unwrap();
4917        assert_eq!(status, 200);
4918
4919        let captured = captured.lock().unwrap();
4920        assert_eq!(captured.len(), 1, "expected exactly one captured request");
4921        let req = &captured[0];
4922        assert_eq!(req.method, "GET");
4923        // `httpMethod`/`allowInternal` are URI options, not request-target
4924        // query params, so the origin-form target is just "/".
4925        assert_eq!(req.path, "/");
4926        assert!(req.body.is_empty(), "GET must not carry a body");
4927        assert!(
4928            req.content_length.is_none(),
4929            "suppressed request must not carry Content-Length"
4930        );
4931        assert!(
4932            req.transfer_encoding.is_none(),
4933            "suppressed request must not carry Transfer-Encoding"
4934        );
4935
4936        // The exchange body is consumed by the producer (std::mem::take).
4937        assert!(
4938            result.input.body.is_empty(),
4939            "exchange body must be consumed"
4940        );
4941    }
4942
4943    #[tokio::test]
4944    async fn test_head_with_body_suppressed_via_header() {
4945        use tower::ServiceExt;
4946
4947        let (url, _handle, captured) = start_capture_server().await;
4948        let ctx = test_producer_ctx();
4949
4950        let component = HttpComponent::with_config(HttpConfig::default());
4951        let endpoint_ctx = NoOpComponentContext;
4952        let endpoint = component
4953            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4954            .unwrap();
4955        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4956
4957        let mut exchange = Exchange::new(Message::default());
4958        exchange.input.set_header(
4959            "CamelHttpMethod",
4960            serde_json::Value::String("HEAD".to_string()),
4961        );
4962        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4963
4964        let result = producer.oneshot(exchange).await.unwrap();
4965        let status = result
4966            .input
4967            .header("CamelHttpResponseCode")
4968            .and_then(|v| v.as_u64())
4969            .unwrap();
4970        assert_eq!(status, 200);
4971
4972        let captured = captured.lock().unwrap();
4973        assert_eq!(captured.len(), 1);
4974        let req = &captured[0];
4975        assert_eq!(req.method, "HEAD");
4976        assert!(req.body.is_empty(), "HEAD must not carry a body");
4977    }
4978
4979    #[tokio::test]
4980    async fn test_delete_options_trace_with_body_suppressed() {
4981        use tower::ServiceExt;
4982
4983        let (url, _handle, captured) = start_capture_server().await;
4984        let ctx = test_producer_ctx();
4985        let component = HttpComponent::with_config(HttpConfig::default());
4986        let endpoint_ctx = NoOpComponentContext;
4987
4988        for method in ["DELETE", "OPTIONS", "TRACE"] {
4989            let endpoint = component
4990                .create_endpoint(
4991                    &format!("{url}?httpMethod={method}&allowInternal=true"),
4992                    &endpoint_ctx,
4993                )
4994                .unwrap();
4995            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4996
4997            let mut exchange = Exchange::new(Message::default());
4998            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4999
5000            let result = producer.oneshot(exchange).await.unwrap();
5001            let status = result
5002                .input
5003                .header("CamelHttpResponseCode")
5004                .and_then(|v| v.as_u64())
5005                .unwrap();
5006            assert_eq!(status, 200, "method {method} should succeed");
5007        }
5008
5009        let captured = captured.lock().unwrap();
5010        assert_eq!(captured.len(), 3, "expected three captured requests");
5011        for method in ["DELETE", "OPTIONS", "TRACE"] {
5012            let req = captured
5013                .iter()
5014                .find(|r| r.method == method)
5015                .unwrap_or_else(|| panic!("missing captured request for {method}"));
5016            assert!(req.body.is_empty(), "{} must not carry a body", req.method);
5017        }
5018    }
5019
5020    #[tokio::test]
5021    async fn test_post_put_patch_with_body_still_sent() {
5022        use tower::ServiceExt;
5023
5024        let (url, _handle, captured) = start_capture_server().await;
5025        let ctx = test_producer_ctx();
5026        let component = HttpComponent::with_config(HttpConfig::default());
5027        let endpoint_ctx = NoOpComponentContext;
5028
5029        for method in ["POST", "PUT", "PATCH"] {
5030            let endpoint = component
5031                .create_endpoint(
5032                    &format!("{url}?httpMethod={method}&allowInternal=true"),
5033                    &endpoint_ctx,
5034                )
5035                .unwrap();
5036            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5037
5038            let payload = format!("body-for-{method}");
5039            let mut exchange = Exchange::new(Message::default());
5040            exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
5041
5042            let result = producer.oneshot(exchange).await.unwrap();
5043            let status = result
5044                .input
5045                .header("CamelHttpResponseCode")
5046                .and_then(|v| v.as_u64())
5047                .unwrap();
5048            assert_eq!(status, 200, "method {method} should succeed");
5049        }
5050
5051        let captured = captured.lock().unwrap();
5052        assert_eq!(captured.len(), 3, "expected three captured requests");
5053        for method in ["POST", "PUT", "PATCH"] {
5054            let req = captured
5055                .iter()
5056                .find(|r| r.method == method)
5057                .unwrap_or_else(|| panic!("missing captured request for {method}"));
5058            let expected = format!("body-for-{method}");
5059            assert!(!req.body.is_empty(), "{method} must still carry its body");
5060            assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
5061        }
5062    }
5063
5064    /// A GET with a stream body must not attach the stream: the entity-enclosing
5065    /// gate drops the stream (mem::take) before the request is built, leaving
5066    /// the exchange body Empty instead of a partially-consumed Body::Stream.
5067    #[tokio::test]
5068    async fn test_stream_body_under_get_not_attached() {
5069        use tower::ServiceExt;
5070
5071        let (url, _handle, captured) = start_capture_server().await;
5072        let ctx = test_producer_ctx();
5073
5074        let component = HttpComponent::with_config(HttpConfig::default());
5075        let endpoint_ctx = NoOpComponentContext;
5076        let endpoint = component
5077            .create_endpoint(
5078                &format!("{url}?httpMethod=GET&allowInternal=true"),
5079                &endpoint_ctx,
5080            )
5081            .unwrap();
5082        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5083
5084        let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5085            vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
5086        let stream = Box::pin(futures::stream::iter(chunks));
5087        let mut exchange = Exchange::new(Message::default());
5088        exchange.input.body = Body::Stream(StreamBody {
5089            stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5090            metadata: StreamMetadata::default(),
5091        });
5092
5093        let result = producer.oneshot(exchange).await.unwrap();
5094
5095        let status = result
5096            .input
5097            .header("CamelHttpResponseCode")
5098            .and_then(|v| v.as_u64())
5099            .unwrap();
5100        assert_eq!(status, 200);
5101
5102        let captured = captured.lock().unwrap();
5103        assert_eq!(captured.len(), 1, "expected exactly one captured request");
5104        assert!(
5105            captured[0].body.is_empty(),
5106            "GET must not carry a stream body"
5107        );
5108        assert!(
5109            captured[0].transfer_encoding.is_none(),
5110            "suppressed request must not carry Transfer-Encoding"
5111        );
5112        assert!(
5113            captured[0].content_length.is_none(),
5114            "suppressed request must not carry Content-Length"
5115        );
5116        assert!(
5117            result.input.body.is_empty(),
5118            "exchange body must be consumed to Empty, not left as a stream"
5119        );
5120    }
5121
5122    /// A suppressed body must never be replayed across 307/308 redirect hops:
5123    /// the gate empties `materialized_body` before the redirect loop runs, so
5124    /// neither the first hop nor the final hop carries the body.
5125    #[tokio::test]
5126    async fn test_redirect_hops_never_replay_suppressed_body() {
5127        use tower::ServiceExt;
5128
5129        let (url, _handle, captured) = start_redirect_capture_server().await;
5130        let ctx = test_producer_ctx();
5131
5132        let component =
5133            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5134        let endpoint_ctx = NoOpComponentContext;
5135
5136        for path in ["/hop307", "/hop308"] {
5137            let endpoint = component
5138                .create_endpoint(
5139                    &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
5140                    &endpoint_ctx,
5141                )
5142                .unwrap();
5143            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5144
5145            let mut exchange = Exchange::new(Message::default());
5146            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5147
5148            let result = producer.oneshot(exchange).await.unwrap();
5149            let status = result
5150                .input
5151                .header("CamelHttpResponseCode")
5152                .and_then(|v| v.as_u64())
5153                .unwrap();
5154            assert_eq!(
5155                status, 200,
5156                "redirect chain for {path} should end at /final"
5157            );
5158        }
5159
5160        // Two chains (307 and 308), each with two hops (redirect + final).
5161        let captured = captured.lock().unwrap();
5162        assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
5163        for req in captured.iter() {
5164            assert!(
5165                req.body.is_empty(),
5166                "hop {} {} must not carry a body",
5167                req.method,
5168                req.path
5169            );
5170        }
5171    }
5172
5173    /// The warn! emitted on a suppressed body renders three distinguishable
5174    /// substrings in the log line (tracing-subscriber default field format):
5175    ///   - the message:       "dropping request body ..."
5176    ///   - `method = %method_str`            → `method=GET`
5177    ///   - `correlation_id = %exchange.correlation_id()` → `correlation_id=<uuid>`
5178    /// The closure matches all three so exactly one warn per suppressed
5179    /// request is required (the "HTTP request" debug! also carries
5180    /// `method=GET` and the same `correlation_id=`, but not the message).
5181    #[tracing_test::traced_test]
5182    #[tokio::test]
5183    async fn test_suppressed_body_logs_exactly_one_warn() {
5184        use tower::ServiceExt;
5185
5186        let (url, _handle, _captured) = start_capture_server().await;
5187        let ctx = test_producer_ctx();
5188
5189        let component = HttpComponent::with_config(HttpConfig::default());
5190        let endpoint_ctx = NoOpComponentContext;
5191        let endpoint = component
5192            .create_endpoint(
5193                &format!("{url}?httpMethod=GET&allowInternal=true"),
5194                &endpoint_ctx,
5195            )
5196            .unwrap();
5197        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5198
5199        let mut exchange = Exchange::new(Message::default());
5200        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5201        let correlation_id = exchange.correlation_id().to_string();
5202
5203        let result = producer.oneshot(exchange).await.unwrap();
5204        let status = result
5205            .input
5206            .header("CamelHttpResponseCode")
5207            .and_then(|v| v.as_u64())
5208            .unwrap();
5209        assert_eq!(status, 200);
5210
5211        logs_assert(|lines: &[&str]| {
5212            let hits = lines
5213                .iter()
5214                .filter(|l| {
5215                    l.contains("dropping request body")
5216                        && l.contains("method=GET")
5217                        && l.contains(&format!("correlation_id={correlation_id}"))
5218                })
5219                .count();
5220            match hits {
5221                1 => Ok(()),
5222                n => Err(format!("expected exactly one body-drop warn, found {n}")),
5223            }
5224        });
5225    }
5226
5227    #[tracing_test::traced_test]
5228    #[tokio::test]
5229    async fn test_empty_body_get_emits_no_warn() {
5230        use tower::ServiceExt;
5231
5232        let (url, _handle, _captured) = start_capture_server().await;
5233        let ctx = test_producer_ctx();
5234
5235        let component = HttpComponent::with_config(HttpConfig::default());
5236        let endpoint_ctx = NoOpComponentContext;
5237        let endpoint = component
5238            .create_endpoint(
5239                &format!("{url}?httpMethod=GET&allowInternal=true"),
5240                &endpoint_ctx,
5241            )
5242            .unwrap();
5243        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5244
5245        let exchange = Exchange::new(Message::default());
5246        let result = producer.oneshot(exchange).await.unwrap();
5247        let status = result
5248            .input
5249            .header("CamelHttpResponseCode")
5250            .and_then(|v| v.as_u64())
5251            .unwrap();
5252        assert_eq!(status, 200);
5253
5254        logs_assert(|lines: &[&str]| {
5255            let hits = lines
5256                .iter()
5257                .filter(|l| l.contains("dropping request body"))
5258                .count();
5259            match hits {
5260                0 => Ok(()),
5261                n => Err(format!("expected no body-drop warn, found {n}")),
5262            }
5263        });
5264    }
5265
5266    #[tokio::test]
5267    async fn test_follow_redirects_false_does_not_follow() {
5268        use tower::ServiceExt;
5269
5270        let (url, _handle) = start_redirect_server().await;
5271        let ctx = test_producer_ctx();
5272
5273        let component =
5274            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
5275        let endpoint_ctx = NoOpComponentContext;
5276        let endpoint = component
5277            .create_endpoint(
5278                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
5279                &endpoint_ctx,
5280            )
5281            .unwrap();
5282        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5283
5284        let exchange = Exchange::new(Message::default());
5285        let result = producer.oneshot(exchange).await.unwrap();
5286
5287        // Should get 302, NOT follow redirect to 200
5288        let status = result
5289            .input
5290            .header("CamelHttpResponseCode")
5291            .and_then(|v| v.as_u64())
5292            .unwrap();
5293        assert_eq!(
5294            status, 302,
5295            "Should NOT follow redirect when followRedirects=false"
5296        );
5297    }
5298
5299    #[tokio::test]
5300    async fn test_follow_redirects_true_follows_redirect() {
5301        use tower::ServiceExt;
5302
5303        let (url, _handle) = start_redirect_server().await;
5304        let ctx = test_producer_ctx();
5305
5306        let component =
5307            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5308        let endpoint_ctx = NoOpComponentContext;
5309        let endpoint = component
5310            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5311            .unwrap();
5312        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5313
5314        let exchange = Exchange::new(Message::default());
5315        let result = producer.oneshot(exchange).await.unwrap();
5316
5317        // Should follow redirect and get 200
5318        let status = result
5319            .input
5320            .header("CamelHttpResponseCode")
5321            .and_then(|v| v.as_u64())
5322            .unwrap();
5323        assert_eq!(
5324            status, 200,
5325            "Should follow redirect when followRedirects=true"
5326        );
5327    }
5328
5329    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
5330    /// This verifies the manual redirect loop executes correctly.
5331    #[tokio::test]
5332    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
5333        use tower::ServiceExt;
5334
5335        // Use the existing redirect server which redirects to /final on the same server
5336        let (url, _handle) = start_redirect_server().await;
5337        let ctx = test_producer_ctx();
5338
5339        let component =
5340            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5341        let endpoint_ctx = NoOpComponentContext;
5342        let endpoint = component
5343            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5344            .unwrap();
5345        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5346
5347        let exchange = Exchange::new(Message::default());
5348        let result = producer.oneshot(exchange).await;
5349
5350        // With allowInternal=true, the redirect should succeed
5351        assert!(
5352            result.is_ok(),
5353            "Redirect should succeed with allowInternal=true, got: {:?}",
5354            result
5355        );
5356        let exchange = result.unwrap();
5357        let status = exchange
5358            .input
5359            .header("CamelHttpResponseCode")
5360            .and_then(|v| v.as_u64())
5361            .unwrap();
5362        assert_eq!(status, 200, "Should follow redirect to /final");
5363    }
5364
5365    /// With allowInternal=true, redirects to private IPs should be followed.
5366    #[tokio::test]
5367    async fn test_redirect_to_private_ip_allowed_when_configured() {
5368        use tower::ServiceExt;
5369
5370        // Start a server that redirects to /final on the same server (127.0.0.1)
5371        let (url, _handle) = start_redirect_server().await;
5372        let ctx = test_producer_ctx();
5373
5374        let component =
5375            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5376        let endpoint_ctx = NoOpComponentContext;
5377        let endpoint = component
5378            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5379            .unwrap();
5380        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5381
5382        let exchange = Exchange::new(Message::default());
5383        let result = producer.oneshot(exchange).await.unwrap();
5384
5385        let status = result
5386            .input
5387            .header("CamelHttpResponseCode")
5388            .and_then(|v| v.as_u64())
5389            .unwrap();
5390        assert_eq!(
5391            status, 200,
5392            "Should follow redirect to private IP when allowInternal=true"
5393        );
5394    }
5395
5396    /// Integration test: with allowInternal=false (default), a redirect to a
5397    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
5398    #[tokio::test]
5399    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
5400        use tower::ServiceExt;
5401
5402        // Server that redirects to the AWS metadata endpoint (link-local private IP)
5403        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5404        let addr = listener.local_addr().unwrap();
5405        let url = format!("http://127.0.0.1:{}", addr.port());
5406
5407        let handle = tokio::spawn(async move {
5408            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5409            loop {
5410                if let Ok((mut stream, _)) = listener.accept().await {
5411                    tokio::spawn(async move {
5412                        let mut buf = vec![0u8; 4096];
5413                        let _ = stream.read(&mut buf).await;
5414                        // Always redirect to the metadata endpoint
5415                        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";
5416                        let _ = stream.write_all(response.as_bytes()).await;
5417                    });
5418                }
5419            }
5420        });
5421
5422        let ctx = test_producer_ctx();
5423        let component =
5424            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5425        let endpoint_ctx = NoOpComponentContext;
5426        // allowInternal=false is the default — do NOT set it
5427        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
5428        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5429
5430        let exchange = Exchange::new(Message::default());
5431        let result = producer.oneshot(exchange).await;
5432
5433        // Must be an error — SSRF guard blocks the redirect target
5434        assert!(
5435            result.is_err(),
5436            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
5437        );
5438        let err = result.unwrap_err().to_string();
5439        assert!(
5440            err.contains("blocked IP")
5441                || err.contains("private IP")
5442                || err.contains("SSRF")
5443                || err.contains("not allowed"),
5444            "Error should mention SSRF/IP blocking, got: {err}"
5445        );
5446
5447        handle.abort();
5448    }
5449
5450    /// Integration test: exceeding maxRedirects produces a clear error.
5451    #[tokio::test]
5452    async fn test_too_many_redirects_returns_error() {
5453        use tower::ServiceExt;
5454
5455        // Server that always redirects to itself (infinite loop)
5456        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5457        let addr = listener.local_addr().unwrap();
5458        let url = format!("http://127.0.0.1:{}", addr.port());
5459
5460        let handle = tokio::spawn(async move {
5461            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5462            loop {
5463                if let Ok((mut stream, _)) = listener.accept().await {
5464                    tokio::spawn(async move {
5465                        let mut buf = vec![0u8; 4096];
5466                        let _ = stream.read(&mut buf).await;
5467                        // Always redirect to /loop
5468                        // Connection: close stops the client pooling the
5469                        // connection the server drops right after this
5470                        // response (pooled-race, rc-u3aw).
5471                        let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5472                        let _ = stream.write_all(response.as_bytes()).await;
5473                    });
5474                }
5475            }
5476        });
5477
5478        let ctx = test_producer_ctx();
5479        let component =
5480            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5481        let endpoint_ctx = NoOpComponentContext;
5482        let endpoint = component
5483            .create_endpoint(
5484                &format!("{url}?allowInternal=true&maxRedirects=2"),
5485                &endpoint_ctx,
5486            )
5487            .unwrap();
5488        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5489
5490        let exchange = Exchange::new(Message::default());
5491        let result = producer.oneshot(exchange).await;
5492
5493        // With the fix, exceeding max redirects returns the redirect response
5494        // as-is instead of erroring. The 302 redirect response is returned
5495        // after followRedirects exhausts the allowed redirect count (2).
5496        // Disable throwExceptionOnFailure to inspect the raw response status.
5497        //
5498        // Old behavior: Err("Too many redirects (max 2)")
5499        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
5500        match result {
5501            Err(e) => {
5502                // If throw_exception_on_failure is on, we get HttpOperationFailed
5503                let msg = e.to_string();
5504                assert!(
5505                    msg.contains("HTTP operation failed") || msg.contains("302"),
5506                    "expected redirect-after-exhaustion error, got: {msg}"
5507                );
5508            }
5509            Ok(ex) => {
5510                let response_code = ex
5511                    .input
5512                    .header("CamelHttpResponseCode")
5513                    .and_then(|v| v.as_u64());
5514                assert_eq!(
5515                    response_code,
5516                    Some(302),
5517                    "expected 302 after exhausting redirects"
5518                );
5519            }
5520        }
5521
5522        handle.abort();
5523    }
5524
5525    #[tokio::test]
5526    async fn test_query_params_forwarded_to_http_request() {
5527        use tower::ServiceExt;
5528
5529        let (url, _handle) = start_test_server().await;
5530        let ctx = test_producer_ctx();
5531
5532        let component = HttpComponent::new();
5533        let endpoint_ctx = NoOpComponentContext;
5534        // apiKey is NOT a Camel option, should be forwarded as query param
5535        let endpoint = component
5536            .create_endpoint(
5537                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
5538                &endpoint_ctx,
5539            )
5540            .unwrap();
5541        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5542
5543        let exchange = Exchange::new(Message::default());
5544        let result = producer.oneshot(exchange).await.unwrap();
5545
5546        // The test server returns the request info in response
5547        // We just verify it succeeds (the query param was sent)
5548        let status = result
5549            .input
5550            .header("CamelHttpResponseCode")
5551            .and_then(|v| v.as_u64())
5552            .unwrap();
5553        assert_eq!(status, 200);
5554    }
5555
5556    #[test]
5557    fn test_non_camel_query_params_are_forwarded() {
5558        // Authored pairs ride raw_query (the sole carrier); query_params is
5559        // programmatic-only (http-query-wire-fidelity).
5560        let config = HttpEndpointConfig::from_uri(
5561            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
5562        )
5563        .unwrap();
5564
5565        // apiKey and token are NOT camel-http options: the authored bytes
5566        // (including the interleaved httpMethod) ride raw_query verbatim.
5567        assert_eq!(
5568            config.raw_query.as_deref(),
5569            Some("apiKey=secret123&httpMethod=GET&token=abc456")
5570        );
5571        assert!(config.query_params.is_empty());
5572    }
5573
5574    #[test]
5575    fn test_authored_query_bytes_survive_resolve_url() {
5576        let config =
5577            HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
5578        let exchange = Exchange::new(Message::default());
5579
5580        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
5581
5582        // Authored bytes ride verbatim: `%20` stays `%20` (never re-encoded
5583        // to `+` or double-encoded) and `+` stays `+`.
5584        assert!(url.contains("q=hello%20world"), "url was: {url}");
5585        assert!(url.contains("tag=a+b"), "url was: {url}");
5586    }
5587
5588    // -----------------------------------------------------------------------
5589    // Timeout tests (HTTP-004)
5590    // -----------------------------------------------------------------------
5591
5592    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
5593        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5594        let addr = listener.local_addr().unwrap();
5595        let url = format!("http://127.0.0.1:{}", addr.port());
5596
5597        let handle = tokio::spawn(async move {
5598            loop {
5599                if let Ok((mut stream, _)) = listener.accept().await {
5600                    let delay = delay_ms;
5601                    tokio::spawn(async move {
5602                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5603                        let mut buf = vec![0u8; 4096];
5604                        let _ = stream.read(&mut buf).await;
5605                        // Send headers immediately (no Content-Length → chunked)
5606                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
5607                        let _ = stream.write_all(headers.as_bytes()).await;
5608                        // Delay before sending body chunk
5609                        tokio::time::sleep(Duration::from_millis(delay)).await;
5610                        let body = r#"{"status":"slow"}"#;
5611                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
5612                        let _ = stream.write_all(chunk.as_bytes()).await;
5613                    });
5614                }
5615            }
5616        });
5617
5618        (url, handle)
5619    }
5620
5621    #[tokio::test]
5622    async fn test_http_producer_timeout() {
5623        use tower::ServiceExt;
5624
5625        // Server delays 500ms, client timeout is 100ms → should timeout
5626        let (url, _handle) = start_slow_server(500).await;
5627        let ctx = test_producer_ctx();
5628
5629        let component = HttpComponent::with_config(
5630            HttpConfig::default()
5631                .with_read_timeout_ms(100)
5632                .with_response_timeout_ms(30_000), // generous response timeout
5633        );
5634        let endpoint_ctx = NoOpComponentContext;
5635        let endpoint = component
5636            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
5637            .unwrap();
5638        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5639
5640        let exchange = Exchange::new(Message::default());
5641        let result = producer.oneshot(exchange).await;
5642
5643        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
5644        let err = result.unwrap_err().to_string();
5645        assert!(
5646            err.contains("Read timeout") || err.contains("timeout"),
5647            "Error should mention timeout, got: {}",
5648            err
5649        );
5650    }
5651
5652    #[tokio::test]
5653    async fn test_http_producer_no_timeout_when_fast() {
5654        use tower::ServiceExt;
5655
5656        let (url, _handle) = start_test_server().await;
5657        let ctx = test_producer_ctx();
5658
5659        let component =
5660            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
5661        let endpoint_ctx = NoOpComponentContext;
5662        let endpoint = component
5663            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5664            .unwrap();
5665        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5666
5667        let exchange = Exchange::new(Message::default());
5668        let result = producer.oneshot(exchange).await.unwrap();
5669
5670        let status = result
5671            .input
5672            .header("CamelHttpResponseCode")
5673            .and_then(|v| v.as_u64())
5674            .unwrap();
5675        assert_eq!(status, 200);
5676    }
5677
5678    // -----------------------------------------------------------------------
5679    // SSRF Protection tests
5680    // -----------------------------------------------------------------------
5681
5682    #[tokio::test]
5683    async fn test_http_producer_blocks_metadata_endpoint() {
5684        use tower::ServiceExt;
5685
5686        let ctx = test_producer_ctx();
5687        let component = HttpComponent::new();
5688        let endpoint_ctx = NoOpComponentContext;
5689        let endpoint = component
5690            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
5691            .unwrap();
5692        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5693
5694        let mut exchange = Exchange::new(Message::default());
5695        exchange.input.set_header(
5696            "CamelHttpUri",
5697            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
5698        );
5699
5700        let result = producer.oneshot(exchange).await;
5701        assert!(result.is_err(), "Should block AWS metadata endpoint");
5702
5703        let err = result.unwrap_err();
5704        assert!(
5705            err.to_string().contains("Private IP"),
5706            "Error should mention private IP blocking, got: {}",
5707            err
5708        );
5709    }
5710
5711    #[test]
5712    fn test_ssrf_config_defaults() {
5713        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
5714        assert!(
5715            !config.allow_internal,
5716            "Private IPs should be blocked by default"
5717        );
5718        assert!(
5719            config.blocked_hosts.is_empty(),
5720            "Blocked hosts should be empty by default"
5721        );
5722    }
5723
5724    #[test]
5725    fn test_ssrf_config_allow_internal() {
5726        let config =
5727            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
5728        assert!(
5729            config.allow_internal,
5730            "Private IPs should be allowed when explicitly set"
5731        );
5732    }
5733
5734    #[test]
5735    fn test_ssrf_config_blocked_hosts() {
5736        let config = HttpEndpointConfig::from_uri(
5737            "http://example.com/api?blockedHosts=evil.com,malware.net",
5738        )
5739        .unwrap();
5740        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
5741    }
5742
5743    #[tokio::test]
5744    async fn test_http_producer_blocks_localhost() {
5745        use tower::ServiceExt;
5746
5747        let ctx = test_producer_ctx();
5748        let component = HttpComponent::new();
5749        let endpoint_ctx = NoOpComponentContext;
5750        let endpoint = component
5751            .create_endpoint("http://example.com/api", &endpoint_ctx)
5752            .unwrap();
5753        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5754
5755        let mut exchange = Exchange::new(Message::default());
5756        exchange.input.set_header(
5757            "CamelHttpUri",
5758            serde_json::Value::String("http://localhost:8080/internal".to_string()),
5759        );
5760
5761        let result = producer.oneshot(exchange).await;
5762        assert!(result.is_err(), "Should block localhost");
5763    }
5764
5765    #[tokio::test]
5766    async fn test_http_producer_blocks_loopback_ip() {
5767        use tower::ServiceExt;
5768
5769        let ctx = test_producer_ctx();
5770        let component = HttpComponent::new();
5771        let endpoint_ctx = NoOpComponentContext;
5772        let endpoint = component
5773            .create_endpoint("http://example.com/api", &endpoint_ctx)
5774            .unwrap();
5775        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5776
5777        let mut exchange = Exchange::new(Message::default());
5778        exchange.input.set_header(
5779            "CamelHttpUri",
5780            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
5781        );
5782
5783        let result = producer.oneshot(exchange).await;
5784        assert!(result.is_err(), "Should block loopback IP");
5785    }
5786
5787    #[tokio::test]
5788    async fn test_http_producer_allows_private_ip_when_enabled() {
5789        use tower::ServiceExt;
5790
5791        let ctx = test_producer_ctx();
5792        let component = HttpComponent::new();
5793        let endpoint_ctx = NoOpComponentContext;
5794        // With allowInternal=true, the validation should pass
5795        // (actual connection will fail, but that's expected)
5796        let endpoint = component
5797            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
5798            .unwrap();
5799        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5800
5801        let exchange = Exchange::new(Message::default());
5802
5803        // The request will fail because we can't connect, but it should NOT fail
5804        // due to SSRF protection
5805        let result = producer.oneshot(exchange).await;
5806        // We expect connection error, not SSRF error
5807        if let Err(ref e) = result {
5808            let err_str = e.to_string();
5809            assert!(
5810                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
5811                "Should not be SSRF error, got: {}",
5812                err_str
5813            );
5814        }
5815    }
5816
5817    // -----------------------------------------------------------------------
5818    // HttpServerConfig tests
5819    // -----------------------------------------------------------------------
5820
5821    #[test]
5822    fn test_http_server_config_parse() {
5823        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
5824        assert_eq!(cfg.host, "0.0.0.0");
5825        assert_eq!(cfg.port, 8080);
5826        assert_eq!(cfg.path, "/orders");
5827        assert_eq!(cfg.max_inflight_requests, 1024);
5828    }
5829
5830    #[test]
5831    fn test_http_server_config_scheme() {
5832        // UriConfig trait method returns "http" as primary scheme
5833        assert_eq!(HttpServerConfig::scheme(), "http");
5834    }
5835
5836    #[test]
5837    fn test_http_server_config_from_components() {
5838        // Test from_components directly (trait method)
5839        let components = camel_component_api::UriComponents {
5840            scheme: "https".to_string(),
5841            path: "//0.0.0.0:8443/api".to_string(),
5842            params: std::collections::HashMap::from([
5843                ("maxRequestBody".to_string(), "5242880".to_string()),
5844                ("maxInflightRequests".to_string(), "7".to_string()),
5845            ]),
5846            raw_query: None,
5847        };
5848        let cfg = HttpServerConfig::from_components(components).unwrap();
5849        assert_eq!(cfg.host, "0.0.0.0");
5850        assert_eq!(cfg.port, 8443);
5851        assert_eq!(cfg.path, "/api");
5852        assert_eq!(cfg.max_request_body, 5242880);
5853        assert_eq!(cfg.max_inflight_requests, 7);
5854    }
5855
5856    #[test]
5857    fn test_http_server_config_default_path() {
5858        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
5859        assert_eq!(cfg.path, "/");
5860    }
5861
5862    #[test]
5863    fn test_http_server_config_wrong_scheme() {
5864        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
5865    }
5866
5867    #[test]
5868    fn test_http_server_config_invalid_port() {
5869        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
5870    }
5871
5872    #[test]
5873    fn test_http_server_config_default_port_by_scheme() {
5874        // HTTP without explicit port should default to 80
5875        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
5876        assert_eq!(cfg_http.port, 80);
5877
5878        // HTTPS without explicit port should default to 443
5879        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
5880        assert_eq!(cfg_https.port, 443);
5881    }
5882
5883    #[test]
5884    fn test_request_envelope_and_reply_are_send() {
5885        fn assert_send<T: Send>() {}
5886        assert_send::<RequestEnvelope>();
5887        assert_send::<HttpReply>();
5888    }
5889
5890    // -----------------------------------------------------------------------
5891    // ServerRegistry tests
5892    // -----------------------------------------------------------------------
5893
5894    #[test]
5895    fn test_server_registry_global_is_singleton() {
5896        let r1 = ServerRegistry::global();
5897        let r2 = ServerRegistry::global();
5898        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
5899    }
5900
5901    #[allow(clippy::await_holding_lock)]
5902    #[tokio::test]
5903    async fn test_concurrent_get_or_spawn_returns_same_registry() {
5904        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5905        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5906        let port = listener.local_addr().unwrap().port();
5907        drop(listener);
5908
5909        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
5910            Arc::new(std::sync::Mutex::new(Vec::new()));
5911
5912        let mut handles = Vec::new();
5913        for _ in 0..4 {
5914            let results = results.clone();
5915            handles.push(tokio::spawn(async move {
5916                let registry = ServerRegistry::global()
5917                    .get_or_spawn(
5918                        "127.0.0.1",
5919                        port,
5920                        2 * 1024 * 1024,
5921                        10 * 1024 * 1024,
5922                        1024,
5923                        test_rt(),
5924                        "test-route".into(),
5925                        None,
5926                    )
5927                    .await
5928                    .unwrap();
5929                results.lock().unwrap().push(registry);
5930            }));
5931        }
5932
5933        for h in handles {
5934            h.await.unwrap();
5935        }
5936
5937        let registries = results.lock().unwrap();
5938        assert_eq!(registries.len(), 4);
5939        for i in 1..registries.len() {
5940            assert!(
5941                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
5942                "all concurrent callers should get same route registry"
5943            );
5944        }
5945    }
5946
5947    #[test]
5948    fn test_server_registry_distinguishes_host_and_port() {
5949        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5950        let rt = tokio::runtime::Runtime::new().expect("runtime");
5951        rt.block_on(async {
5952            let registry = ServerRegistry::global();
5953            // Use two distinct host values with same configured port key.
5954            // Port 0 is acceptable here because the registry key uses the configured
5955            // tuple, not the OS-assigned ephemeral port.
5956            let d1 = registry
5957                .get_or_spawn(
5958                    "127.0.0.1",
5959                    0,
5960                    1024 * 1024,
5961                    10 * 1024 * 1024,
5962                    1024,
5963                    test_rt(),
5964                    "test-route-1".into(),
5965                    None,
5966                )
5967                .await;
5968            let d2 = registry
5969                .get_or_spawn(
5970                    "0.0.0.0",
5971                    0,
5972                    1024 * 1024,
5973                    10 * 1024 * 1024,
5974                    1024,
5975                    test_rt(),
5976                    "test-route-2".into(),
5977                    None,
5978                )
5979                .await;
5980            assert!(d1.is_ok());
5981            assert!(d2.is_ok());
5982            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
5983        });
5984    }
5985
5986    #[allow(clippy::await_holding_lock)]
5987    #[tokio::test]
5988    async fn test_shared_server_max_request_body_policy_is_deterministic() {
5989        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5990        let registry = ServerRegistry::global();
5991        // First registration: maxRequestBody = 1 MB
5992        let d1 = registry
5993            .get_or_spawn(
5994                "127.0.0.1",
5995                9991,
5996                1024 * 1024,
5997                10 * 1024 * 1024,
5998                1024,
5999                test_rt(),
6000                "test-route".into(),
6001                None,
6002            )
6003            .await;
6004        assert!(d1.is_ok());
6005
6006        // Second registration on same (host,port): maxRequestBody = 2 MB
6007        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
6008        let d2 = registry
6009            .get_or_spawn(
6010                "127.0.0.1",
6011                9991,
6012                2 * 1024 * 1024,
6013                10 * 1024 * 1024,
6014                1024,
6015                test_rt(),
6016                "test-route-2".into(),
6017                None,
6018            )
6019            .await;
6020        assert!(d2.is_err());
6021        let err = d2.unwrap_err();
6022        assert!(
6023            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
6024            "Expected incompatible maxRequestBody error, got: {}",
6025            err
6026        );
6027    }
6028
6029    #[test]
6030    fn test_server_registry_reset_clears_entries() {
6031        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6032        let rt = tokio::runtime::Runtime::new().expect("runtime");
6033        rt.block_on(async {
6034            // Register something on a unique port
6035            let d1 = ServerRegistry::global()
6036                .get_or_spawn(
6037                    "127.0.0.1",
6038                    9992,
6039                    1024 * 1024,
6040                    10 * 1024 * 1024,
6041                    1024,
6042                    test_rt(),
6043                    "test-route".into(),
6044                    None,
6045                )
6046                .await;
6047            assert!(d1.is_ok());
6048
6049            // Verify entry exists
6050            let guard = ServerRegistry::global().inner.lock().expect("lock");
6051            assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
6052            drop(guard);
6053
6054            // Reset
6055            ServerRegistry::reset();
6056
6057            // Verify cleared
6058            let guard = ServerRegistry::global().inner.lock().expect("lock");
6059            assert!(
6060                guard.entries.is_empty(),
6061                "registry should be empty after reset, has {} entries",
6062                guard.entries.len()
6063            );
6064        });
6065    }
6066
6067    #[tokio::test]
6068    async fn registry_rejects_tls_on_plain_port() {
6069        ServerRegistry::reset();
6070        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6071
6072        // First route: plain HTTP
6073        let _r1 = ServerRegistry::global()
6074            .get_or_spawn(
6075                "127.0.0.1",
6076                0,
6077                1024,
6078                1024,
6079                16,
6080                Arc::clone(&rt),
6081                "route-1".into(),
6082                None, // plain
6083            )
6084            .await;
6085
6086        // Second route: TLS on same port → must fail
6087        let result = ServerRegistry::global()
6088            .get_or_spawn(
6089                "127.0.0.1",
6090                0,
6091                1024,
6092                1024,
6093                16,
6094                Arc::clone(&rt),
6095                "route-2".into(),
6096                Some(crate::config::ServerTlsConfig {
6097                    cert_path: "/x.pem".into(),
6098                    key_path: "/y.pem".into(),
6099                }),
6100            )
6101            .await;
6102        assert!(result.is_err(), "must reject TLS on plain port");
6103    }
6104
6105    // -----------------------------------------------------------------------
6106    // D-L10: HTTP monitor_axum_task refcounted shutdown
6107    // -----------------------------------------------------------------------
6108
6109    #[allow(clippy::await_holding_lock)]
6110    #[tokio::test]
6111    async fn test_unregister_last_http_route_keeps_server_alive() {
6112        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6113        ServerRegistry::reset();
6114        let registry = ServerRegistry::global();
6115
6116        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6117        let port = listener.local_addr().unwrap().port();
6118        drop(listener); // Release — ServerRegistry will rebind
6119        let rt = test_rt();
6120
6121        // Register 2 routes on the same (host, port) — OnceCell returns the
6122        // same ServerHandle.
6123        let _r1 = registry
6124            .get_or_spawn(
6125                "127.0.0.1",
6126                port,
6127                1024 * 1024,
6128                10 * 1024 * 1024,
6129                16,
6130                rt.clone(),
6131                "test-route-1".into(),
6132                None,
6133            )
6134            .await
6135            .unwrap();
6136        let _r2 = registry
6137            .get_or_spawn(
6138                "127.0.0.1",
6139                port,
6140                1024 * 1024,
6141                10 * 1024 * 1024,
6142                16,
6143                rt,
6144                "test-route-2".into(),
6145                None,
6146            )
6147            .await
6148            .unwrap();
6149
6150        let key = ("127.0.0.1".to_string(), port);
6151        let cell = {
6152            let guard = registry.inner.lock().expect("lock");
6153            guard.entries.get(&key).expect("entry should exist").clone()
6154        };
6155
6156        // Unregister first route -> monitor still alive (count = 1).
6157        registry.unregister("127.0.0.1", port).await;
6158        {
6159            let handle = cell
6160                .get()
6161                .expect("handle should still exist after first unregister");
6162            assert!(
6163                !handle.monitor_task.is_finished(),
6164                "monitor task should still be alive after first unregister"
6165            );
6166        }
6167
6168        // Unregister second route -> server stays alive (process-lifetime).
6169        registry.unregister("127.0.0.1", port).await;
6170        tokio::time::sleep(Duration::from_millis(20)).await;
6171        {
6172            let handle = cell
6173                .get()
6174                .expect("handle should still exist after last unregister");
6175            assert!(
6176                !handle.monitor_task.is_finished(),
6177                "monitor task should still be alive — server is process-lifetime"
6178            );
6179        }
6180
6181        // Entry stays in registry for potential restart.
6182        {
6183            let guard = registry.inner.lock().expect("lock");
6184            assert!(
6185                guard.entries.contains_key(&key),
6186                "entry should remain in registry — server kept alive for restart"
6187            );
6188        }
6189    }
6190
6191    // -----------------------------------------------------------------------
6192    // Staged listeners (itest-bound-ports Task 1)
6193    // -----------------------------------------------------------------------
6194
6195    /// CLONE-FIXTURE: bind a std listener on `127.0.0.1:0`, retain a blocking
6196    /// std clone (`probe`) so the port stays reserved, and hand the original
6197    /// socket to tokio as a non-blocking listener. `tokio::net::TcpListener`
6198    /// has no `try_clone`, so clones come from the std handle.
6199    async fn clone_fixture_listener() -> (
6200        tokio::net::TcpListener,
6201        std::net::TcpListener,
6202        std::net::SocketAddr,
6203    ) {
6204        let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
6205        let probe = l.try_clone().expect("clone probe");
6206        l.set_nonblocking(true).expect("set_nonblocking");
6207        let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
6208        let addr = listener.local_addr().expect("local_addr");
6209        (listener, probe, addr)
6210    }
6211
6212    /// Default-limit constants the existing registry tests in this file use.
6213    fn staged_limits() -> (usize, usize, usize) {
6214        (1024 * 1024, 10 * 1024 * 1024, 1024)
6215    }
6216
6217    #[allow(clippy::await_holding_lock)]
6218    #[tokio::test]
6219    async fn staged_listener_first_spawn_serves_without_second_bind() {
6220        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6221        ServerRegistry::reset();
6222        let registry = ServerRegistry::global();
6223        let (listener, _probe, addr) = clone_fixture_listener().await;
6224        let port = addr.port();
6225        registry
6226            .stage_listener(listener)
6227            .await
6228            .expect("stage listener");
6229
6230        let (max_req, max_res, max_inflight) = staged_limits();
6231        let routes = registry
6232            .get_or_spawn(
6233                "127.0.0.1",
6234                port,
6235                max_req,
6236                max_res,
6237                max_inflight,
6238                test_rt(),
6239                "staged-first-spawn".into(),
6240                None,
6241            )
6242            .await
6243            .expect("spawn from staged listener must succeed");
6244
6245        assert_eq!(
6246            registry.bound_addr("127.0.0.1", port),
6247            Some(addr),
6248            "served socket must be the staged listener's addr"
6249        );
6250        // The probe clone shares the socket, so service is proven by an HTTP
6251        // response, not by accepting on the probe.
6252        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
6253            .await
6254            .expect("http request against staged listener must connect");
6255        assert!(
6256            resp.status().as_u16() >= 200,
6257            "any status proves the staged socket serves"
6258        );
6259        drop(routes);
6260    }
6261
6262    #[allow(clippy::await_holding_lock)]
6263    #[tokio::test]
6264    async fn staged_entry_reused_by_second_caller() {
6265        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6266        ServerRegistry::reset();
6267        let registry = ServerRegistry::global();
6268        let (listener, _probe, addr) = clone_fixture_listener().await;
6269        let port = addr.port();
6270        registry
6271            .stage_listener(listener)
6272            .await
6273            .expect("stage listener");
6274
6275        let (max_req, max_res, max_inflight) = staged_limits();
6276        let first = registry
6277            .get_or_spawn(
6278                "127.0.0.1",
6279                port,
6280                max_req,
6281                max_res,
6282                max_inflight,
6283                test_rt(),
6284                "staged-reuse-1".into(),
6285                None,
6286            )
6287            .await
6288            .expect("first spawn from staged listener");
6289        let second = registry
6290            .get_or_spawn(
6291                "127.0.0.1",
6292                port,
6293                max_req,
6294                max_res,
6295                max_inflight,
6296                test_rt(),
6297                "staged-reuse-2".into(),
6298                None,
6299            )
6300            .await
6301            .expect("second caller must reuse the entry");
6302        assert_eq!(
6303            registry.bound_addr("127.0.0.1", port),
6304            Some(addr),
6305            "entry reused — bound addr unchanged, no second bind"
6306        );
6307        drop(first);
6308        drop(second);
6309    }
6310
6311    #[allow(clippy::await_holding_lock)]
6312    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6313    async fn staged_race_two_callers_single_resolver() {
6314        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6315        ServerRegistry::reset();
6316        let registry = ServerRegistry::global();
6317        let (listener, _probe, addr) = clone_fixture_listener().await;
6318        let port = addr.port();
6319        registry
6320            .stage_listener(listener)
6321            .await
6322            .expect("stage listener");
6323
6324        // Two racing callers for the exact staged key: the staged listener
6325        // must be consumed by the single cell-init winner and served to
6326        // both — never leave the winner binding a port the loser still
6327        // holds (EADDRINUSE).
6328        let (max_req, max_res, max_inflight) = staged_limits();
6329        let (first, second) = tokio::join!(
6330            registry.get_or_spawn(
6331                "127.0.0.1",
6332                port,
6333                max_req,
6334                max_res,
6335                max_inflight,
6336                test_rt(),
6337                "staged-race-1".into(),
6338                None,
6339            ),
6340            registry.get_or_spawn(
6341                "127.0.0.1",
6342                port,
6343                max_req,
6344                max_res,
6345                max_inflight,
6346                test_rt(),
6347                "staged-race-2".into(),
6348                None,
6349            ),
6350        );
6351        let first = first.expect("first racing caller must succeed");
6352        let second = second.expect("second racing caller must succeed");
6353        assert_eq!(
6354            registry.bound_addr("127.0.0.1", port),
6355            Some(addr),
6356            "single entry must be served from the staged socket — no EADDRINUSE path"
6357        );
6358        drop(first);
6359        drop(second);
6360    }
6361
6362    #[allow(clippy::await_holding_lock)]
6363    #[tokio::test]
6364    async fn unstaged_spawn_binds_legacy() {
6365        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6366        ServerRegistry::reset();
6367        let registry = ServerRegistry::global();
6368        // Fresh port P2: reserve then release — the legacy path rebinds.
6369        let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
6370        let port = probe.local_addr().expect("local addr").port();
6371        drop(probe);
6372
6373        let (max_req, max_res, max_inflight) = staged_limits();
6374        registry
6375            .get_or_spawn(
6376                "127.0.0.1",
6377                port,
6378                max_req,
6379                max_res,
6380                max_inflight,
6381                test_rt(),
6382                "legacy-bind".into(),
6383                None,
6384            )
6385            .await
6386            .expect("legacy bind spawn");
6387        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
6388            .await
6389            .expect("connect to freshly bound port must succeed");
6390        assert!(resp.status().as_u16() >= 200);
6391        assert_eq!(
6392            registry.bound_addr("127.0.0.1", port),
6393            Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
6394            "bound addr must be the legacy bound (host, port)"
6395        );
6396    }
6397
6398    #[allow(clippy::await_holding_lock)]
6399    #[tokio::test]
6400    async fn wrong_host_staged_port_fails_deterministically() {
6401        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6402        ServerRegistry::reset();
6403        let registry = ServerRegistry::global();
6404        let (listener, _probe, addr) = clone_fixture_listener().await;
6405        let port = addr.port();
6406        registry
6407            .stage_listener(listener)
6408            .await
6409            .expect("stage listener under 127.0.0.1");
6410
6411        let (max_req, max_res, max_inflight) = staged_limits();
6412        let err = registry
6413            .get_or_spawn(
6414                "localhost",
6415                port,
6416                max_req,
6417                max_res,
6418                max_inflight,
6419                test_rt(),
6420                "conflict-probe".into(),
6421                None,
6422            )
6423            .await
6424            .expect_err("wrong host on staged port must fail deterministically");
6425        assert!(
6426            err.to_string().contains("staged listener conflict on port"),
6427            "unexpected error: {err}"
6428        );
6429
6430        // Slot untouched by the failed call: the correct host now consumes it.
6431        registry
6432            .get_or_spawn(
6433                "127.0.0.1",
6434                port,
6435                max_req,
6436                max_res,
6437                max_inflight,
6438                test_rt(),
6439                "conflict-after".into(),
6440                None,
6441            )
6442            .await
6443            .expect("correct host must serve the staged listener");
6444        assert_eq!(
6445            registry.bound_addr("127.0.0.1", port),
6446            Some(addr),
6447            "staged slot must be untouched by the conflicting call"
6448        );
6449    }
6450
6451    #[allow(clippy::await_holding_lock)]
6452    #[tokio::test]
6453    async fn duplicate_stage_same_key_rejected() {
6454        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6455        ServerRegistry::reset();
6456        let registry = ServerRegistry::global();
6457        let (listener, probe, addr) = clone_fixture_listener().await;
6458        registry
6459            .stage_listener(listener)
6460            .await
6461            .expect("stage listener A");
6462
6463        // Second tokio handle to the SAME socket: clone the std probe handle.
6464        let dup = probe.try_clone().expect("clone2");
6465        dup.set_nonblocking(true).expect("set_nonblocking2");
6466        let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
6467
6468        let err = registry
6469            .stage_listener(b)
6470            .await
6471            .expect_err("duplicate stage must be rejected");
6472        assert!(
6473            err.to_string().contains("listener already staged"),
6474            "unexpected error: {err}"
6475        );
6476
6477        let (max_req, max_res, max_inflight) = staged_limits();
6478        registry
6479            .get_or_spawn(
6480                "127.0.0.1",
6481                addr.port(),
6482                max_req,
6483                max_res,
6484                max_inflight,
6485                test_rt(),
6486                "dup-stage-after".into(),
6487                None,
6488            )
6489            .await
6490            .expect("spawn from first staged listener");
6491        assert_eq!(
6492            registry.bound_addr("127.0.0.1", addr.port()),
6493            Some(addr),
6494            "first staged listener retained"
6495        );
6496    }
6497
6498    #[allow(clippy::await_holding_lock)]
6499    #[tokio::test]
6500    async fn distinct_keys_stage_independently() {
6501        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6502        ServerRegistry::reset();
6503        let registry = ServerRegistry::global();
6504        let (l1, _p1, addr1) = clone_fixture_listener().await;
6505        let (l2, _p2, addr2) = clone_fixture_listener().await;
6506        registry.stage_listener(l1).await.expect("stage P1");
6507        registry.stage_listener(l2).await.expect("stage P2");
6508
6509        let (max_req, max_res, max_inflight) = staged_limits();
6510        registry
6511            .get_or_spawn(
6512                "127.0.0.1",
6513                addr1.port(),
6514                max_req,
6515                max_res,
6516                max_inflight,
6517                test_rt(),
6518                "distinct-1".into(),
6519                None,
6520            )
6521            .await
6522            .expect("spawn P1");
6523        registry
6524            .get_or_spawn(
6525                "127.0.0.1",
6526                addr2.port(),
6527                max_req,
6528                max_res,
6529                max_inflight,
6530                test_rt(),
6531                "distinct-2".into(),
6532                None,
6533            )
6534            .await
6535            .expect("spawn P2");
6536        assert_eq!(
6537            registry.bound_addr("127.0.0.1", addr1.port()),
6538            Some(addr1),
6539            "P1 bound addr must be its own listener"
6540        );
6541        assert_eq!(
6542            registry.bound_addr("127.0.0.1", addr2.port()),
6543            Some(addr2),
6544            "P2 bound addr must be its own listener"
6545        );
6546        let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
6547            .await
6548            .expect("connect P1");
6549        assert!(r1.status().as_u16() >= 200);
6550        let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
6551            .await
6552            .expect("connect P2");
6553        assert!(r2.status().as_u16() >= 200);
6554    }
6555
6556    #[allow(clippy::await_holding_lock)]
6557    #[tokio::test]
6558    async fn tls_prebound_listener_served() {
6559        use camel_component_api::test_support::tls;
6560
6561        // Install rustls crypto provider (aws-lc-rs — matches the existing
6562        // TLS registry tests).
6563        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6564
6565        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6566        ServerRegistry::reset();
6567        let registry = ServerRegistry::global();
6568        let (listener, _probe, addr) = clone_fixture_listener().await;
6569        let port = addr.port();
6570
6571        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
6572        let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
6573        let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
6574        let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
6575
6576        let (max_req, max_res, max_inflight) = staged_limits();
6577        let routes = registry
6578            .get_or_spawn_with_listener(
6579                listener,
6580                max_req,
6581                max_res,
6582                max_inflight,
6583                test_rt(),
6584                "staged-tls".into(),
6585                Some(crate::config::ServerTlsConfig {
6586                    cert_path: cert_path.to_string_lossy().into_owned(),
6587                    key_path: key_path.to_string_lossy().into_owned(),
6588                }),
6589            )
6590            .await
6591            .expect("spawn TLS server from pre-bound listener");
6592
6593        // Client with CA cert — REAL verification (no danger_accept_invalid),
6594        // same helper pattern as the existing TLS registry tests.
6595        let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
6596        let client = reqwest::Client::builder()
6597            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
6598            .build()
6599            .expect("build tls client");
6600
6601        let resp = client
6602            .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
6603            .send()
6604            .await
6605            .expect("TLS handshake + request must succeed");
6606        assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
6607        assert_eq!(
6608            registry.bound_addr("127.0.0.1", port),
6609            Some(addr),
6610            "bound addr equals the pre-bound listener addr"
6611        );
6612        drop(routes);
6613    }
6614
6615    #[allow(clippy::await_holding_lock)]
6616    #[tokio::test]
6617    async fn with_listener_direct_spawn_keyed_by_actual_addr() {
6618        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6619        ServerRegistry::reset();
6620        let registry = ServerRegistry::global();
6621        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
6622            .await
6623            .expect("bind un-staged listener");
6624        let addr = listener.local_addr().expect("local addr");
6625        let port = addr.port();
6626
6627        let (max_req, max_res, max_inflight) = staged_limits();
6628        registry
6629            .get_or_spawn_with_listener(
6630                listener,
6631                max_req,
6632                max_res,
6633                max_inflight,
6634                test_rt(),
6635                "with-listener".into(),
6636                None,
6637            )
6638            .await
6639            .expect("direct spawn from un-staged listener");
6640        let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
6641            .await
6642            .expect("connect on actual port");
6643        assert!(resp.status().as_u16() >= 200);
6644        assert_eq!(
6645            registry.bound_addr("127.0.0.1", port),
6646            Some(addr),
6647            "registry key is the listener's actual port"
6648        );
6649
6650        registry
6651            .get_or_spawn(
6652                "127.0.0.1",
6653                port,
6654                max_req,
6655                max_res,
6656                max_inflight,
6657                test_rt(),
6658                "with-listener-reuse".into(),
6659                None,
6660            )
6661            .await
6662            .expect("legacy caller must reuse the entry");
6663        assert_eq!(
6664            registry.bound_addr("127.0.0.1", port),
6665            Some(addr),
6666            "entry reused — no second bind"
6667        );
6668    }
6669
6670    // -----------------------------------------------------------------------
6671    // Axum dispatch handler tests
6672    // -----------------------------------------------------------------------
6673
6674    #[tokio::test]
6675    async fn test_dispatch_handler_returns_404_for_unknown_path() {
6676        let registry = HttpRouteRegistry::new();
6677        // Nothing registered in route registry
6678        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6679        let port = listener.local_addr().unwrap().port();
6680        tokio::spawn(run_axum_server(
6681            listener,
6682            registry,
6683            2 * 1024 * 1024,
6684            10 * 1024 * 1024,
6685            Arc::new(tokio::sync::Semaphore::new(1024)),
6686            test_rt(),
6687            "test-route".into(),
6688        ));
6689
6690        // Wait for server to start
6691        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
6692
6693        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
6694            .await
6695            .unwrap();
6696        assert_eq!(resp.status().as_u16(), 404);
6697    }
6698
6699    // -----------------------------------------------------------------------
6700    // HttpConsumer tests
6701    // -----------------------------------------------------------------------
6702
6703    #[tokio::test]
6704    async fn test_http_consumer_start_registers_path() {
6705        use camel_component_api::ConsumerContext;
6706
6707        // Get an OS-assigned free port
6708        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6709        let port = listener.local_addr().unwrap().port();
6710        drop(listener); // Release port — ServerRegistry will rebind it
6711
6712        let consumer_cfg = HttpServerConfig {
6713            scheme: "http".to_string(),
6714            host: "127.0.0.1".to_string(),
6715            port,
6716            path: "/ping".to_string(),
6717            max_request_body: 2 * 1024 * 1024,
6718            max_response_body: 10 * 1024 * 1024,
6719            max_inflight_requests: 1024,
6720            method: None,
6721            tls_config: None,
6722        };
6723        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6724
6725        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6726        let token = tokio_util::sync::CancellationToken::new();
6727        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6728
6729        tokio::spawn(async move {
6730            consumer.start(ctx).await.unwrap();
6731        });
6732
6733        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6734
6735        let client = reqwest::Client::new();
6736        let resp_future = client
6737            .post(format!("http://127.0.0.1:{port}/ping"))
6738            .body("hello world")
6739            .send();
6740
6741        let (http_result, _) = tokio::join!(resp_future, async {
6742            if let Some(mut envelope) = rx.recv().await {
6743                // Set a custom status code
6744                envelope.exchange.input.set_header(
6745                    "CamelHttpResponseCode",
6746                    serde_json::Value::Number(201.into()),
6747                );
6748                if let Some(reply_tx) = envelope.reply_tx {
6749                    let _ = reply_tx.send(Ok(envelope.exchange));
6750                }
6751            }
6752        });
6753
6754        let resp = http_result.unwrap();
6755        assert_eq!(resp.status().as_u16(), 201);
6756
6757        token.cancel();
6758    }
6759
6760    /// rc-3y6j: the RequestEnvelope channel capacity must mirror the
6761    /// dispatcher's inflight semaphore so the semaphore stays the single
6762    /// backpressure point. Zero maps to 1 because `mpsc::channel(0)` panics.
6763    #[test]
6764    fn test_envelope_channel_capacity_follows_max_inflight() {
6765        assert_eq!(envelope_channel_capacity(0), 1);
6766        assert_eq!(envelope_channel_capacity(1), 1);
6767        assert_eq!(envelope_channel_capacity(7), 7);
6768        assert_eq!(envelope_channel_capacity(64), 64);
6769        assert_eq!(envelope_channel_capacity(1024), 1024);
6770    }
6771
6772    /// rc-3y6j: `maxInflightRequests=0` is a representable reject-everything
6773    /// configuration. Consumer start must not panic on it (the channel guard)
6774    /// and every request must get 503 from the empty semaphore.
6775    #[tokio::test]
6776    async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
6777        use camel_component_api::ConsumerContext;
6778
6779        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6780        let port = listener.local_addr().unwrap().port();
6781        drop(listener);
6782
6783        let consumer_cfg = HttpServerConfig {
6784            scheme: "http".to_string(),
6785            host: "127.0.0.1".to_string(),
6786            port,
6787            path: "/ping".to_string(),
6788            max_request_body: 2 * 1024 * 1024,
6789            max_response_body: 10 * 1024 * 1024,
6790            max_inflight_requests: 0,
6791            method: None,
6792            tls_config: None,
6793        };
6794        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6795
6796        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6797        let token = tokio_util::sync::CancellationToken::new();
6798        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6799
6800        let start_handle = tokio::spawn(async move {
6801            consumer.start(ctx).await.unwrap();
6802        });
6803
6804        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6805
6806        let client = reqwest::Client::new();
6807        let resp = client
6808            .post(format!("http://127.0.0.1:{port}/ping"))
6809            .body("hello world")
6810            .send()
6811            .await
6812            .unwrap();
6813        assert_eq!(resp.status().as_u16(), 503);
6814
6815        token.cancel();
6816        let _ = start_handle.await;
6817    }
6818
6819    /// rc-w1u9: HttpConsumer MUST declare Explicit startup_mode so the runtime
6820    /// waits for the listener bind before publishing RouteStarted.
6821    #[test]
6822    fn test_http_consumer_startup_mode_is_explicit() {
6823        use camel_component_api::ConsumerStartupMode;
6824        let consumer_cfg = HttpServerConfig {
6825            scheme: "http".to_string(),
6826            host: "127.0.0.1".to_string(),
6827            port: 0,
6828            path: "/x".to_string(),
6829            max_request_body: 2 * 1024 * 1024,
6830            max_response_body: 10 * 1024 * 1024,
6831            max_inflight_requests: 1024,
6832            method: None,
6833            tls_config: None,
6834        };
6835        let consumer = HttpConsumer::new(consumer_cfg, test_rt());
6836        assert_eq!(
6837            consumer.startup_mode(),
6838            ConsumerStartupMode::Explicit,
6839            "HttpConsumer must opt into Explicit startup"
6840        );
6841    }
6842
6843    /// rc-w1u9: HttpConsumer::start() MUST call ctx.mark_ready() AFTER bind
6844    /// + route registration. The StartupSignal resolves Ok only when that
6845    /// happens. Verified here by injecting our own signal pair into the
6846    /// ConsumerContext and asserting the receiver resolves within a bounded
6847    /// window even before any HTTP request is made.
6848    #[allow(clippy::await_holding_lock)]
6849    #[tokio::test]
6850    async fn test_http_consumer_emits_mark_ready_after_bind() {
6851        use camel_component_api::{ConsumerContext, StartupSignal};
6852
6853        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6854
6855        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6856        let port = listener.local_addr().unwrap().port();
6857        drop(listener);
6858
6859        let consumer_cfg = HttpServerConfig {
6860            scheme: "http".to_string(),
6861            host: "127.0.0.1".to_string(),
6862            port,
6863            path: "/ready-probe".to_string(),
6864            max_request_body: 2 * 1024 * 1024,
6865            max_response_body: 10 * 1024 * 1024,
6866            max_inflight_requests: 1024,
6867            method: None,
6868            tls_config: None,
6869        };
6870        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6871
6872        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6873        let token = tokio_util::sync::CancellationToken::new();
6874        let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
6875
6876        // Inject our own startup signal so we can observe mark_ready.
6877        let (signal, startup_rx) = StartupSignal::pair();
6878        let ctx = ctx.with_startup(signal);
6879
6880        // Spawn start() — it MUST call mark_ready once the listener is bound
6881        // and the path is registered.
6882        tokio::spawn(async move {
6883            let _ = consumer.start(ctx).await;
6884        });
6885
6886        // The receiver MUST resolve Ok within a bounded window — proving
6887        // mark_ready was called by start(). A short timeout catches the
6888        // regression where mark_ready is never called (the old behaviour
6889        // would hang the receiver forever, which is exactly the rc-w1u9 bug).
6890        let result =
6891            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
6892                .await
6893                .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
6894        assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
6895
6896        // Cancellation tears down the spawned start() loop.
6897        token.cancel();
6898    }
6899
6900    #[tokio::test]
6901    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
6902        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6903
6904        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6905        let port = listener.local_addr().unwrap().port();
6906        drop(listener);
6907
6908        let consumer_cfg = HttpServerConfig {
6909            scheme: "http".to_string(),
6910            host: "127.0.0.1".to_string(),
6911            port,
6912            path: "/saturation".to_string(),
6913            max_request_body: 2 * 1024 * 1024,
6914            max_response_body: 10 * 1024 * 1024,
6915            max_inflight_requests: 1,
6916            method: None,
6917            tls_config: None,
6918        };
6919        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6920
6921        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6922        let token = tokio_util::sync::CancellationToken::new();
6923        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6924        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6925        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6926
6927        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
6928        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
6929
6930        tokio::spawn(async move {
6931            let mut first_seen_tx = Some(first_seen_tx);
6932            let mut unblock_first_rx = Some(unblock_first_rx);
6933
6934            while let Some(envelope) = rx.recv().await {
6935                if let Some(tx) = first_seen_tx.take() {
6936                    let _ = tx.send(());
6937                    if let Some(rx_unblock) = unblock_first_rx.take() {
6938                        let _ = rx_unblock.await;
6939                    }
6940                }
6941
6942                if let Some(reply_tx) = envelope.reply_tx {
6943                    let _ = reply_tx.send(Ok(envelope.exchange));
6944                }
6945            }
6946        });
6947
6948        let client = reqwest::Client::new();
6949        let first_req = {
6950            let client = client.clone();
6951            async move {
6952                client
6953                    .get(format!("http://127.0.0.1:{port}/saturation"))
6954                    .send()
6955                    .await
6956                    .unwrap()
6957            }
6958        };
6959
6960        let first_handle = tokio::spawn(first_req);
6961        first_seen_rx.await.unwrap();
6962
6963        let second_resp = client
6964            .get(format!("http://127.0.0.1:{port}/saturation"))
6965            .send()
6966            .await
6967            .unwrap();
6968
6969        assert_eq!(second_resp.status().as_u16(), 503);
6970
6971        let _ = unblock_first_tx.send(());
6972        let first_resp = first_handle.await.unwrap();
6973        assert_eq!(first_resp.status().as_u16(), 200);
6974
6975        token.cancel();
6976    }
6977
6978    /// Audit 2026-08-31, F2-1: a chunked (no Content-Length) request body must
6979    /// still be capped — the byte limit travels with the stream, so any
6980    /// downstream materialization fails closed past `max_request_body`.
6981    #[tokio::test]
6982    async fn test_http_consumer_chunked_body_is_capped() {
6983        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6984
6985        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6986        let port = listener.local_addr().unwrap().port();
6987        drop(listener);
6988
6989        let consumer_cfg = HttpServerConfig {
6990            scheme: "http".to_string(),
6991            host: "127.0.0.1".to_string(),
6992            port,
6993            path: "/chunked-cap".to_string(),
6994            max_request_body: 1024, // tiny cap for the test
6995            max_response_body: 10 * 1024 * 1024,
6996            max_inflight_requests: 16,
6997            method: None,
6998            tls_config: None,
6999        };
7000        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7001
7002        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7003        let token = tokio_util::sync::CancellationToken::new();
7004        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7005        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7006        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7007
7008        // Chunked body: reqwest streams it without Content-Length.
7009        let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
7010            .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
7011            .collect();
7012        let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
7013
7014        let client = reqwest::Client::new();
7015        let send_fut = client
7016            .post(format!("http://127.0.0.1:{port}/chunked-cap"))
7017            .body(stream_body)
7018            .send();
7019
7020        let (http_result, _) = tokio::join!(send_fut, async {
7021            if let Some(mut envelope) = rx.recv().await {
7022                // The route materializes the body — the cap must fire.
7023                let materialized = envelope
7024                    .exchange
7025                    .input
7026                    .body
7027                    .clone()
7028                    .into_bytes(64 * 1024)
7029                    .await;
7030                assert!(
7031                    materialized.is_err(),
7032                    "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
7033                );
7034                let err = materialized.unwrap_err().to_string();
7035                assert!(
7036                    err.contains("limit") || err.contains("exceeds"),
7037                    "error should mention the limit: {err}"
7038                );
7039                if let Some(reply_tx) = envelope.reply_tx {
7040                    envelope.exchange.input.body =
7041                        camel_component_api::Body::Text("handled".to_string());
7042                    let _ = reply_tx.send(Ok(envelope.exchange));
7043                }
7044            }
7045        });
7046
7047        let resp = http_result.unwrap();
7048        assert_eq!(resp.status().as_u16(), 200);
7049
7050        token.cancel();
7051    }
7052
7053    #[tokio::test]
7054    #[allow(clippy::await_holding_lock)]
7055    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
7056        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7057
7058        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7059
7060        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7061        let port = listener.local_addr().unwrap().port();
7062        drop(listener);
7063
7064        let consumer_cfg = HttpServerConfig {
7065            scheme: "http".to_string(),
7066            host: "127.0.0.1".to_string(),
7067            port,
7068            path: "/limit-bytes".to_string(),
7069            max_request_body: 2 * 1024 * 1024,
7070            max_response_body: 16,
7071            max_inflight_requests: 1024,
7072            method: None,
7073            tls_config: None,
7074        };
7075        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7076
7077        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7078        let token = tokio_util::sync::CancellationToken::new();
7079        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7080        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7081        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7082
7083        let client = reqwest::Client::new();
7084        let send_fut = client
7085            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
7086            .send();
7087
7088        let (http_result, _) = tokio::join!(send_fut, async {
7089            if let Some(mut envelope) = rx.recv().await {
7090                envelope.exchange.input.body =
7091                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
7092                if let Some(reply_tx) = envelope.reply_tx {
7093                    let _ = reply_tx.send(Ok(envelope.exchange));
7094                }
7095            }
7096        });
7097
7098        let resp = http_result.unwrap();
7099        assert_eq!(resp.status().as_u16(), 500);
7100        let body = resp.text().await.unwrap();
7101        assert_eq!(body, "Response body exceeds configured limit");
7102        token.cancel();
7103    }
7104
7105    #[tokio::test]
7106    #[allow(clippy::await_holding_lock)]
7107    async fn test_http_consumer_enforces_max_response_body_for_json() {
7108        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7109
7110        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7111
7112        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7113        let port = listener.local_addr().unwrap().port();
7114        drop(listener);
7115
7116        let consumer_cfg = HttpServerConfig {
7117            scheme: "http".to_string(),
7118            host: "127.0.0.1".to_string(),
7119            port,
7120            path: "/limit-json".to_string(),
7121            max_request_body: 2 * 1024 * 1024,
7122            max_response_body: 16,
7123            max_inflight_requests: 1024,
7124            method: None,
7125            tls_config: None,
7126        };
7127        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7128
7129        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7130        let token = tokio_util::sync::CancellationToken::new();
7131        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7132        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7133        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7134
7135        let client = reqwest::Client::new();
7136        let send_fut = client
7137            .get(format!("http://127.0.0.1:{port}/limit-json"))
7138            .send();
7139
7140        let (http_result, _) = tokio::join!(send_fut, async {
7141            if let Some(mut envelope) = rx.recv().await {
7142                envelope.exchange.input.body = camel_component_api::Body::Json(
7143                    serde_json::json!({"message":"this response is bigger than sixteen"}),
7144                );
7145                if let Some(reply_tx) = envelope.reply_tx {
7146                    let _ = reply_tx.send(Ok(envelope.exchange));
7147                }
7148            }
7149        });
7150
7151        let resp = http_result.unwrap();
7152        assert_eq!(resp.status().as_u16(), 500);
7153        let body = resp.text().await.unwrap();
7154        assert_eq!(body, "Response body exceeds configured limit");
7155        token.cancel();
7156    }
7157
7158    #[tokio::test]
7159    #[allow(clippy::await_holding_lock)]
7160    async fn test_http_consumer_enforces_max_response_body_for_xml() {
7161        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7162
7163        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7164
7165        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7166        let port = listener.local_addr().unwrap().port();
7167        drop(listener);
7168
7169        let consumer_cfg = HttpServerConfig {
7170            scheme: "http".to_string(),
7171            host: "127.0.0.1".to_string(),
7172            port,
7173            path: "/limit-xml".to_string(),
7174            max_request_body: 2 * 1024 * 1024,
7175            max_response_body: 16,
7176            max_inflight_requests: 1024,
7177            method: None,
7178            tls_config: None,
7179        };
7180        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7181
7182        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7183        let token = tokio_util::sync::CancellationToken::new();
7184        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7185        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7186        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7187
7188        let client = reqwest::Client::new();
7189        let send_fut = client
7190            .get(format!("http://127.0.0.1:{port}/limit-xml"))
7191            .send();
7192
7193        let (http_result, _) = tokio::join!(send_fut, async {
7194            if let Some(mut envelope) = rx.recv().await {
7195                envelope.exchange.input.body = camel_component_api::Body::Xml(
7196                    "<root><value>way-too-large</value></root>".into(),
7197                );
7198                if let Some(reply_tx) = envelope.reply_tx {
7199                    let _ = reply_tx.send(Ok(envelope.exchange));
7200                }
7201            }
7202        });
7203
7204        let resp = http_result.unwrap();
7205        assert_eq!(resp.status().as_u16(), 500);
7206        let body = resp.text().await.unwrap();
7207        assert_eq!(body, "Response body exceeds configured limit");
7208        token.cancel();
7209    }
7210
7211    #[tokio::test]
7212    #[allow(clippy::await_holding_lock)]
7213    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
7214        use camel_component_api::{
7215            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
7216        };
7217        use futures::stream;
7218
7219        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7220
7221        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
7222        let port = listener.local_addr().unwrap().port();
7223        drop(listener);
7224
7225        let consumer_cfg = HttpServerConfig {
7226            scheme: "http".to_string(),
7227            host: "0.0.0.0".to_string(),
7228            port,
7229            path: "/limit-stream".to_string(),
7230            max_request_body: 2 * 1024 * 1024,
7231            max_response_body: 16,
7232            max_inflight_requests: 1024,
7233            method: None,
7234            tls_config: None,
7235        };
7236        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7237
7238        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7239        let token = tokio_util::sync::CancellationToken::new();
7240        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7241        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7242        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7243
7244        let client = reqwest::Client::new();
7245        let send_fut = client
7246            .get(format!("http://127.0.0.1:{port}/limit-stream"))
7247            .send();
7248
7249        let (http_result, _) = tokio::join!(send_fut, async {
7250            if let Some(mut envelope) = rx.recv().await {
7251                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
7252                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
7253                let stream = Box::pin(stream::iter(chunks));
7254                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
7255                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
7256                    metadata: StreamMetadata {
7257                        size_hint: Some(32),
7258                        content_type: Some("application/octet-stream".into()),
7259                        origin: None,
7260                    },
7261                });
7262                if let Some(reply_tx) = envelope.reply_tx {
7263                    let _ = reply_tx.send(Ok(envelope.exchange));
7264                }
7265            }
7266        });
7267
7268        let resp = http_result.unwrap();
7269        assert_eq!(resp.status().as_u16(), 200);
7270        let body = resp.bytes().await.unwrap();
7271        assert_eq!(body.len(), 32);
7272        token.cancel();
7273    }
7274
7275    // -----------------------------------------------------------------------
7276    // Integration tests
7277    // -----------------------------------------------------------------------
7278
7279    #[tokio::test]
7280    #[allow(clippy::await_holding_lock)]
7281    async fn test_integration_single_consumer_round_trip() {
7282        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7283
7284        // Spawns an HTTP consumer on the global ServerRegistry
7285        // (HttpConsumer::start → get_or_spawn). Serialize against the other
7286        // registry tests so parallel runs do not race on shared global state.
7287        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7288
7289        // Get an OS-assigned free port (ephemeral)
7290        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7291        let port = listener.local_addr().unwrap().port();
7292        drop(listener); // Release — ServerRegistry will rebind
7293
7294        let component = HttpComponent::new();
7295        let endpoint_ctx = NoOpComponentContext;
7296        let endpoint = component
7297            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
7298            .unwrap();
7299        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7300
7301        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7302        let token = tokio_util::sync::CancellationToken::new();
7303        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7304
7305        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7306        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7307
7308        let client = reqwest::Client::new();
7309        let send_fut = client
7310            .post(format!("http://127.0.0.1:{port}/echo"))
7311            .header("Content-Type", "text/plain")
7312            .body("ping")
7313            .send();
7314
7315        let (http_result, _) = tokio::join!(send_fut, async {
7316            if let Some(mut envelope) = rx.recv().await {
7317                assert_eq!(
7318                    envelope.exchange.input.header("CamelHttpMethod"),
7319                    Some(&serde_json::Value::String("POST".into()))
7320                );
7321                assert_eq!(
7322                    envelope.exchange.input.header("CamelHttpPath"),
7323                    Some(&serde_json::Value::String("/echo".into()))
7324                );
7325                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7326                if let Some(reply_tx) = envelope.reply_tx {
7327                    let _ = reply_tx.send(Ok(envelope.exchange));
7328                }
7329            }
7330        });
7331
7332        let resp = http_result.unwrap();
7333        assert_eq!(resp.status().as_u16(), 200);
7334        let body = resp.text().await.unwrap();
7335        assert_eq!(body, "pong");
7336
7337        token.cancel();
7338    }
7339
7340    #[tokio::test]
7341    #[allow(clippy::await_holding_lock)]
7342    async fn test_integration_two_consumers_shared_port() {
7343        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7344
7345        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7346
7347        // Get an OS-assigned free port (ephemeral)
7348        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7349        let port = listener.local_addr().unwrap().port();
7350        drop(listener);
7351
7352        let component = HttpComponent::new();
7353        let endpoint_ctx = NoOpComponentContext;
7354
7355        // Consumer A: /hello
7356        let endpoint_a = component
7357            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
7358            .unwrap();
7359        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
7360
7361        // Consumer B: /world
7362        let endpoint_b = component
7363            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
7364            .unwrap();
7365        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
7366
7367        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7368        let token_a = tokio_util::sync::CancellationToken::new();
7369        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
7370
7371        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7372        let token_b = tokio_util::sync::CancellationToken::new();
7373        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
7374
7375        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
7376        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
7377        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7378
7379        let client = reqwest::Client::new();
7380
7381        // Request to /hello
7382        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
7383        let (resp_hello, _) = tokio::join!(fut_hello, async {
7384            if let Some(mut envelope) = rx_a.recv().await {
7385                envelope.exchange.input.body =
7386                    camel_component_api::Body::Text("hello-response".to_string());
7387                if let Some(reply_tx) = envelope.reply_tx {
7388                    let _ = reply_tx.send(Ok(envelope.exchange));
7389                }
7390            }
7391        });
7392
7393        // Request to /world
7394        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
7395        let (resp_world, _) = tokio::join!(fut_world, async {
7396            if let Some(mut envelope) = rx_b.recv().await {
7397                envelope.exchange.input.body =
7398                    camel_component_api::Body::Text("world-response".to_string());
7399                if let Some(reply_tx) = envelope.reply_tx {
7400                    let _ = reply_tx.send(Ok(envelope.exchange));
7401                }
7402            }
7403        });
7404
7405        let body_a = resp_hello.unwrap().text().await.unwrap();
7406        let body_b = resp_world.unwrap().text().await.unwrap();
7407
7408        assert_eq!(body_a, "hello-response");
7409        assert_eq!(body_b, "world-response");
7410
7411        token_a.cancel();
7412        token_b.cancel();
7413    }
7414
7415    #[tokio::test]
7416    #[allow(clippy::await_holding_lock)]
7417    async fn test_integration_unregistered_path_returns_404() {
7418        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7419
7420        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7421
7422        // Get an OS-assigned free port (ephemeral)
7423        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7424        let port = listener.local_addr().unwrap().port();
7425        drop(listener);
7426
7427        let component = HttpComponent::new();
7428        let endpoint_ctx = NoOpComponentContext;
7429        let endpoint = component
7430            .create_endpoint(
7431                &format!("http://127.0.0.1:{port}/registered"),
7432                &endpoint_ctx,
7433            )
7434            .unwrap();
7435        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7436
7437        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7438        let token = tokio_util::sync::CancellationToken::new();
7439        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7440
7441        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7442
7443        // Wait until the server is actually accepting connections (CI runners can be slow).
7444        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7445        loop {
7446            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
7447                .await
7448                .is_ok()
7449            {
7450                break;
7451            }
7452            if std::time::Instant::now() >= deadline {
7453                panic!("HTTP server did not start within 5s on port {port}");
7454            }
7455            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7456        }
7457
7458        let client = reqwest::Client::new();
7459        let resp = client
7460            .get(format!("http://127.0.0.1:{port}/not-there"))
7461            .send()
7462            .await
7463            .unwrap();
7464        assert_eq!(resp.status().as_u16(), 404);
7465
7466        token.cancel();
7467    }
7468
7469    #[test]
7470    fn test_http_consumer_declares_concurrent() {
7471        use camel_component_api::ConcurrencyModel;
7472
7473        let config = HttpServerConfig {
7474            scheme: "http".to_string(),
7475            host: "127.0.0.1".to_string(),
7476            port: 19999,
7477            path: "/test".to_string(),
7478            max_request_body: 2 * 1024 * 1024,
7479            max_response_body: 10 * 1024 * 1024,
7480            max_inflight_requests: 1024,
7481            method: None,
7482            tls_config: None,
7483        };
7484        let consumer = HttpConsumer::new(config, test_rt());
7485        assert_eq!(
7486            consumer.concurrency_model(),
7487            ConcurrencyModel::Concurrent { max: None }
7488        );
7489    }
7490
7491    #[test]
7492    fn server_config_parses_tls_cert_and_key() {
7493        let cfg = HttpServerConfig::from_uri(
7494            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
7495        )
7496        .unwrap();
7497        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
7498        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
7499    }
7500
7501    #[test]
7502    fn server_config_no_tls_when_params_absent() {
7503        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
7504        assert!(cfg.tls_config.is_none());
7505    }
7506
7507    // -----------------------------------------------------------------------
7508    // HttpReplyBody streaming tests
7509    // -----------------------------------------------------------------------
7510
7511    #[tokio::test]
7512    async fn test_http_reply_body_stream_variant_exists() {
7513        use bytes::Bytes;
7514        use camel_component_api::CamelError;
7515        use futures::stream;
7516
7517        let chunks: Vec<Result<Bytes, CamelError>> =
7518            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
7519        let stream = Box::pin(stream::iter(chunks));
7520        let reply_body = HttpReplyBody::Stream(stream);
7521        // Si compila y el match funciona, el test pasa
7522        match reply_body {
7523            HttpReplyBody::Stream(_) => {}
7524            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
7525        }
7526    }
7527
7528    // -----------------------------------------------------------------------
7529    // OpenTelemetry propagation tests (only compiled with "otel" feature)
7530    // -----------------------------------------------------------------------
7531
7532    #[cfg(feature = "otel")]
7533    mod otel_tests {
7534        use super::*;
7535        use camel_component_api::Message;
7536        use tower::ServiceExt;
7537
7538        #[tokio::test]
7539        async fn test_producer_injects_traceparent_header() {
7540            let (url, _handle) = start_test_server_with_header_capture().await;
7541            let ctx = test_producer_ctx();
7542
7543            let component = HttpComponent::new();
7544            let endpoint_ctx = NoOpComponentContext;
7545            let endpoint = component
7546                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7547                .unwrap();
7548            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7549
7550            // Create exchange with an OTel context by extracting from a traceparent header
7551            let mut exchange = Exchange::new(Message::default());
7552            let mut headers = std::collections::HashMap::new();
7553            headers.insert(
7554                "traceparent".to_string(),
7555                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
7556            );
7557            camel_otel::extract_into_exchange(&mut exchange, &headers);
7558
7559            let result = producer.oneshot(exchange).await.unwrap();
7560
7561            // Verify request succeeded
7562            let status = result
7563                .input
7564                .header("CamelHttpResponseCode")
7565                .and_then(|v| v.as_u64())
7566                .unwrap();
7567            assert_eq!(status, 200);
7568
7569            // The test server echoes back the received traceparent header
7570            let traceparent = result.input.header("X-Received-Traceparent");
7571            assert!(
7572                traceparent.is_some(),
7573                "traceparent header should have been sent"
7574            );
7575
7576            let traceparent_str = traceparent.unwrap().as_str().unwrap();
7577            // Verify format: version-traceid-spanid-flags
7578            let parts: Vec<&str> = traceparent_str.split('-').collect();
7579            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7580            assert_eq!(parts[0], "00", "version should be 00");
7581            assert_eq!(
7582                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7583                "trace-id should match"
7584            );
7585            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
7586            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
7587        }
7588
7589        #[tokio::test]
7590        async fn test_consumer_extracts_traceparent_header() {
7591            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7592
7593            // Get an OS-assigned free port
7594            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7595            let port = listener.local_addr().unwrap().port();
7596            drop(listener);
7597
7598            let component = HttpComponent::new();
7599            let endpoint_ctx = NoOpComponentContext;
7600            let endpoint = component
7601                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7602                .unwrap();
7603            let mut consumer = endpoint.create_consumer(rt()).unwrap();
7604
7605            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7606            let token = tokio_util::sync::CancellationToken::new();
7607            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7608
7609            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7610            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7611
7612            // Send request with traceparent header
7613            let client = reqwest::Client::new();
7614            let send_fut = client
7615                .post(format!("http://127.0.0.1:{port}/trace"))
7616                .header(
7617                    "traceparent",
7618                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7619                )
7620                .body("test")
7621                .send();
7622
7623            let (http_result, _) = tokio::join!(send_fut, async {
7624                if let Some(envelope) = rx.recv().await {
7625                    // Verify the exchange has a valid OTel context by re-injecting it
7626                    // and checking the traceparent matches
7627                    let mut injected_headers = std::collections::HashMap::new();
7628                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7629
7630                    assert!(
7631                        injected_headers.contains_key("traceparent"),
7632                        "Exchange should have traceparent after extraction"
7633                    );
7634
7635                    let traceparent = injected_headers.get("traceparent").unwrap();
7636                    let parts: Vec<&str> = traceparent.split('-').collect();
7637                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7638                    assert_eq!(
7639                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7640                        "Trace ID should match the original traceparent header"
7641                    );
7642
7643                    if let Some(reply_tx) = envelope.reply_tx {
7644                        let _ = reply_tx.send(Ok(envelope.exchange));
7645                    }
7646                }
7647            });
7648
7649            let resp = http_result.unwrap();
7650            assert_eq!(resp.status().as_u16(), 200);
7651
7652            token.cancel();
7653        }
7654
7655        #[tokio::test]
7656        async fn test_consumer_extracts_mixed_case_traceparent_header() {
7657            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7658
7659            // Get an OS-assigned free port
7660            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7661            let port = listener.local_addr().unwrap().port();
7662            drop(listener);
7663
7664            let component = HttpComponent::new();
7665            let endpoint_ctx = NoOpComponentContext;
7666            let endpoint = component
7667                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7668                .unwrap();
7669            let mut consumer = endpoint.create_consumer(rt()).unwrap();
7670
7671            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7672            let token = tokio_util::sync::CancellationToken::new();
7673            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7674
7675            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7676            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7677
7678            // Send request with MIXED-CASE TraceParent header (not lowercase)
7679            let client = reqwest::Client::new();
7680            let send_fut = client
7681                .post(format!("http://127.0.0.1:{port}/trace"))
7682                .header(
7683                    "TraceParent",
7684                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7685                )
7686                .body("test")
7687                .send();
7688
7689            let (http_result, _) = tokio::join!(send_fut, async {
7690                if let Some(envelope) = rx.recv().await {
7691                    // Verify the exchange has a valid OTel context by re-injecting it
7692                    // and checking the traceparent matches
7693                    let mut injected_headers = HashMap::new();
7694                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7695
7696                    assert!(
7697                        injected_headers.contains_key("traceparent"),
7698                        "Exchange should have traceparent after extraction from mixed-case header"
7699                    );
7700
7701                    let traceparent = injected_headers.get("traceparent").unwrap();
7702                    let parts: Vec<&str> = traceparent.split('-').collect();
7703                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7704                    assert_eq!(
7705                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7706                        "Trace ID should match the original mixed-case TraceParent header"
7707                    );
7708
7709                    if let Some(reply_tx) = envelope.reply_tx {
7710                        let _ = reply_tx.send(Ok(envelope.exchange));
7711                    }
7712                }
7713            });
7714
7715            let resp = http_result.unwrap();
7716            assert_eq!(resp.status().as_u16(), 200);
7717
7718            token.cancel();
7719        }
7720
7721        #[tokio::test]
7722        async fn test_producer_no_trace_context_no_crash() {
7723            let (url, _handle) = start_test_server().await;
7724            let ctx = test_producer_ctx();
7725
7726            let component = HttpComponent::new();
7727            let endpoint_ctx = NoOpComponentContext;
7728            let endpoint = component
7729                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7730                .unwrap();
7731            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7732
7733            // Create exchange with default (empty) otel_context - no trace context
7734            let exchange = Exchange::new(Message::default());
7735
7736            // Should succeed without panic
7737            let result = producer.oneshot(exchange).await.unwrap();
7738
7739            // Verify request succeeded
7740            let status = result
7741                .input
7742                .header("CamelHttpResponseCode")
7743                .and_then(|v| v.as_u64())
7744                .unwrap();
7745            assert_eq!(status, 200);
7746        }
7747
7748        /// Test server that captures and echoes back the traceparent header
7749        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
7750            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7751            let addr = listener.local_addr().unwrap();
7752            let url = format!("http://127.0.0.1:{}", addr.port());
7753
7754            let handle = tokio::spawn(async move {
7755                loop {
7756                    if let Ok((mut stream, _)) = listener.accept().await {
7757                        tokio::spawn(async move {
7758                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
7759                            let mut buf = vec![0u8; 8192];
7760                            let n = stream.read(&mut buf).await.unwrap_or(0);
7761                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
7762
7763                            // Extract traceparent header from request
7764                            let traceparent = request
7765                                .lines()
7766                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
7767                                .map(|line| {
7768                                    line.split(':')
7769                                        .nth(1)
7770                                        .map(|s| s.trim().to_string())
7771                                        .unwrap_or_default()
7772                                })
7773                                .unwrap_or_default();
7774
7775                            let body =
7776                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
7777                            let response = format!(
7778                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
7779                                body.len(),
7780                                traceparent,
7781                                body
7782                            );
7783                            let _ = stream.write_all(response.as_bytes()).await;
7784                        });
7785                    }
7786                }
7787            });
7788
7789            (url, handle)
7790        }
7791    }
7792
7793    // -----------------------------------------------------------------------
7794    // Response streaming tests (Eje A - Task 2)
7795    // -----------------------------------------------------------------------
7796
7797    // -----------------------------------------------------------------------
7798    // Request streaming tests (Eje B - Task 3)
7799    // -----------------------------------------------------------------------
7800
7801    #[tokio::test]
7802    async fn test_request_body_arrives_as_stream() {
7803        use camel_component_api::Body;
7804        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7805
7806        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7807        let port = listener.local_addr().unwrap().port();
7808        drop(listener);
7809
7810        let component = HttpComponent::new();
7811        let endpoint_ctx = NoOpComponentContext;
7812        let endpoint = component
7813            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
7814            .unwrap();
7815        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7816
7817        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7818        let token = tokio_util::sync::CancellationToken::new();
7819        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7820
7821        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7822        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7823
7824        let client = reqwest::Client::new();
7825        let send_fut = client
7826            .post(format!("http://127.0.0.1:{port}/upload"))
7827            .body("hello streaming world")
7828            .send();
7829
7830        let (http_result, _) = tokio::join!(send_fut, async {
7831            if let Some(mut envelope) = rx.recv().await {
7832                // Body must be Body::Stream, not Body::Text or Body::Bytes
7833                assert!(
7834                    matches!(envelope.exchange.input.body, Body::Stream(_)),
7835                    "expected Body::Stream, got discriminant {:?}",
7836                    std::mem::discriminant(&envelope.exchange.input.body)
7837                );
7838                // Materialize to verify content
7839                let bytes = envelope
7840                    .exchange
7841                    .input
7842                    .body
7843                    .into_bytes(1024 * 1024)
7844                    .await
7845                    .unwrap();
7846                assert_eq!(&bytes[..], b"hello streaming world");
7847
7848                envelope.exchange.input.body = camel_component_api::Body::Empty;
7849                if let Some(reply_tx) = envelope.reply_tx {
7850                    let _ = reply_tx.send(Ok(envelope.exchange));
7851                }
7852            }
7853        });
7854
7855        let resp = http_result.unwrap();
7856        assert_eq!(resp.status().as_u16(), 200);
7857
7858        token.cancel();
7859    }
7860
7861    // -----------------------------------------------------------------------
7862    // Response streaming tests (Eje A - Task 2)
7863    // -----------------------------------------------------------------------
7864
7865    #[tokio::test]
7866    async fn test_streaming_response_chunked() {
7867        use bytes::Bytes;
7868        use camel_component_api::Body;
7869        use camel_component_api::CamelError;
7870        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7871        use camel_component_api::{StreamBody, StreamMetadata};
7872        use futures::stream;
7873        use std::sync::Arc;
7874        use tokio::sync::Mutex;
7875
7876        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7877        let port = listener.local_addr().unwrap().port();
7878        drop(listener);
7879
7880        let component = HttpComponent::new();
7881        let endpoint_ctx = NoOpComponentContext;
7882        let endpoint = component
7883            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
7884            .unwrap();
7885        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7886
7887        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7888        let token = tokio_util::sync::CancellationToken::new();
7889        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7890
7891        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7892        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7893
7894        let client = reqwest::Client::new();
7895        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
7896
7897        let (http_result, _) = tokio::join!(send_fut, async {
7898            if let Some(mut envelope) = rx.recv().await {
7899                // Respond with Body::Stream
7900                let chunks: Vec<Result<Bytes, CamelError>> =
7901                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
7902                let stream = Box::pin(stream::iter(chunks));
7903                envelope.exchange.input.body = Body::Stream(StreamBody {
7904                    stream: Arc::new(Mutex::new(Some(stream))),
7905                    metadata: StreamMetadata::default(),
7906                });
7907                if let Some(reply_tx) = envelope.reply_tx {
7908                    let _ = reply_tx.send(Ok(envelope.exchange));
7909                }
7910            }
7911        });
7912
7913        let resp = http_result.unwrap();
7914        assert_eq!(resp.status().as_u16(), 200);
7915        let body = resp.text().await.unwrap();
7916        assert_eq!(body, "chunk1chunk2");
7917
7918        token.cancel();
7919    }
7920
7921    // -----------------------------------------------------------------------
7922    // 413 Content-Length limit test (Task 4)
7923    // -----------------------------------------------------------------------
7924
7925    #[tokio::test]
7926    async fn test_413_when_content_length_exceeds_limit() {
7927        use camel_component_api::ConsumerContext;
7928
7929        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7930        let port = listener.local_addr().unwrap().port();
7931        drop(listener);
7932
7933        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
7934        let component = HttpComponent::new();
7935        let endpoint_ctx = NoOpComponentContext;
7936        let endpoint = component
7937            .create_endpoint(
7938                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
7939                &endpoint_ctx,
7940            )
7941            .unwrap();
7942        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7943
7944        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7945        let token = tokio_util::sync::CancellationToken::new();
7946        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7947
7948        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7949        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7950
7951        let client = reqwest::Client::new();
7952        let resp = client
7953            .post(format!("http://127.0.0.1:{port}/upload"))
7954            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
7955            .body("x".repeat(1000))
7956            .send()
7957            .await
7958            .unwrap();
7959
7960        assert_eq!(resp.status().as_u16(), 413);
7961
7962        token.cancel();
7963    }
7964
7965    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
7966    /// The spec says: "If there is no Content-Length, the limit does not apply at the
7967    /// consumer level — the route is responsible."
7968    #[tokio::test]
7969    async fn test_chunked_upload_without_content_length_bypasses_limit() {
7970        use bytes::Bytes;
7971        use camel_component_api::Body;
7972        use camel_component_api::ConsumerContext;
7973        use futures::stream;
7974
7975        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7976        let port = listener.local_addr().unwrap().port();
7977        drop(listener);
7978
7979        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
7980        let component = HttpComponent::new();
7981        let endpoint_ctx = NoOpComponentContext;
7982        let endpoint = component
7983            .create_endpoint(
7984                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
7985                &endpoint_ctx,
7986            )
7987            .unwrap();
7988        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7989
7990        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7991        let token = tokio_util::sync::CancellationToken::new();
7992        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7993
7994        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7995        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7996
7997        let client = reqwest::Client::new();
7998
7999        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
8000        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
8001        // but since there's no Content-Length the 413 check must NOT fire.
8002        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
8003            Ok(Bytes::from("y".repeat(50))),
8004            Ok(Bytes::from("y".repeat(50))),
8005        ];
8006        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
8007        let send_fut = client
8008            .post(format!("http://127.0.0.1:{port}/upload"))
8009            .body(stream_body)
8010            .send();
8011
8012        let consumer_fut = async {
8013            // Use timeout to avoid deadlock if the handler rejects before enqueueing
8014            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
8015                Ok(Some(mut envelope)) => {
8016                    assert!(
8017                        matches!(envelope.exchange.input.body, Body::Stream(_)),
8018                        "expected Body::Stream"
8019                    );
8020                    envelope.exchange.input.body = camel_component_api::Body::Empty;
8021                    if let Some(reply_tx) = envelope.reply_tx {
8022                        let _ = reply_tx.send(Ok(envelope.exchange));
8023                    }
8024                }
8025                Ok(None) => panic!("consumer channel closed unexpectedly"),
8026                Err(_) => {
8027                    // Timeout: the request was rejected before reaching the consumer.
8028                    // The HTTP response will carry the real status code (we check below).
8029                }
8030            }
8031        };
8032
8033        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
8034
8035        let resp = http_result.unwrap();
8036        // Audit 2026-08-31 (F2-1): the request is no longer rejected at the door
8037        // (no Content-Length to pre-check), but the byte cap now travels with the
8038        // stream: ANY materialization past maxRequestBody fails closed. This test
8039        // does not consume the body, so the request still completes with 200 —
8040        // enforcement happens at consumption time (see
8041        // test_http_consumer_chunked_body_is_capped).
8042        assert_ne!(
8043            resp.status().as_u16(),
8044            413,
8045            "chunked upload has no Content-Length to pre-check"
8046        );
8047        assert_eq!(resp.status().as_u16(), 200);
8048
8049        token.cancel();
8050    }
8051
8052    #[test]
8053    fn test_is_private_ip_ranges() {
8054        use camel_api::is_ssrf_blocked_ip;
8055        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
8056        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
8057        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
8058        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
8059        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
8060        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
8061
8062        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
8063        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
8064        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
8065        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
8066        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
8067        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
8068        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
8069        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
8070
8071        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
8072        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
8073        assert!(!is_ssrf_blocked_ip(
8074            &"2001:4860:4860::8888".parse().unwrap()
8075        )); // allow-unwrap
8076    }
8077
8078    #[test]
8079    fn test_title_case_header() {
8080        assert_eq!(title_case_header("content-type"), "Content-Type");
8081        assert_eq!(title_case_header("authorization"), "Authorization");
8082        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
8083        assert_eq!(title_case_header("host"), "Host");
8084        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
8085        assert_eq!(title_case_header("single"), "Single");
8086        assert_eq!(title_case_header(""), "");
8087    }
8088
8089    #[test]
8090    fn test_resolve_url_combines_path_and_query_sources() {
8091        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
8092        let mut exchange = Exchange::new(Message::default());
8093        exchange.input.set_header(
8094            "CamelHttpPath",
8095            serde_json::Value::String("next".to_string()),
8096        );
8097        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8098        assert!(url.starts_with("http://example.com/base/next?"));
8099        assert!(url.contains("foo=bar"));
8100
8101        exchange.input.set_header(
8102            "CamelHttpUri",
8103            serde_json::Value::String("http://other.test/root".to_string()),
8104        );
8105        exchange.input.set_header(
8106            "CamelHttpQuery",
8107            serde_json::Value::String("a=1&b=2".to_string()),
8108        );
8109
8110        let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8111        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
8112    }
8113
8114    fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
8115        let mut exchange = Exchange::new(Message::default());
8116        exchange
8117            .input
8118            .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
8119        exchange.input.set_header(
8120            "CamelHttpQuery",
8121            serde_json::Value::String(query.to_string()),
8122        );
8123        exchange
8124    }
8125
8126    #[test]
8127    fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
8128        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8129        cfg.bridge_endpoint = true;
8130        cfg.query_params
8131            .push(("token".to_string(), "secret".to_string()));
8132        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
8133        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8134        // Verbatim assembly: the old round-trip normalized the empty base
8135        // path to `/` (`http://x/?token=secret`); authored bytes end-to-end
8136        // no longer insert it.
8137        assert_eq!(url, "http://x?token=secret");
8138        assert!(!url.contains("/foo"));
8139        assert!(!url.contains("dropme"));
8140    }
8141
8142    #[test]
8143    fn resolve_url_bridge_endpoint_false_merges_path() {
8144        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8145        cfg.bridge_endpoint = false;
8146        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
8147        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8148        assert!(url.contains("/foo"), "url should contain /foo: {url}");
8149        assert!(
8150            url.contains("dropme=1"),
8151            "url should contain dropme=1: {url}"
8152        );
8153    }
8154
8155    #[test]
8156    fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
8157        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8158        cfg.bridge_endpoint = true;
8159        let mut exchange = Exchange::new(Message::default());
8160        exchange.input.set_header(
8161            "CamelHttpPath",
8162            serde_json::Value::String("/foo".to_string()),
8163        );
8164        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8165        assert_eq!(url, "http://x");
8166        assert!(!url.contains("/foo"));
8167    }
8168
8169    #[test]
8170    fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
8171        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8172        cfg.bridge_endpoint = true;
8173        // query_params stays empty ([])
8174        let mut exchange = Exchange::new(Message::default());
8175        exchange.input.set_header(
8176            "CamelHttpUri",
8177            serde_json::Value::String("http://dest/explicit".to_string()),
8178        );
8179        exchange.input.set_header(
8180            "CamelHttpPath",
8181            serde_json::Value::String("/foo".to_string()),
8182        );
8183        exchange.input.set_header(
8184            "CamelHttpQuery",
8185            serde_json::Value::String("x=1".to_string()),
8186        );
8187        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8188        // Under bridgeEndpoint=true ALL exchange URL headers (CamelHttpUri,
8189        // CamelHttpPath, CamelHttpQuery) are ignored; the endpoint base URL
8190        // wins verbatim.
8191        assert_eq!(url, "http://x");
8192    }
8193
8194    #[test]
8195    fn bridge_programmatic_params_use_percent20() {
8196        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8197        cfg.bridge_endpoint = true;
8198        cfg.query_params = vec![("b".to_string(), "x y".to_string())];
8199        let exchange = Exchange::new(Message::default());
8200
8201        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8202
8203        // `%20 never +` is global for programmatic values — the bridge arm
8204        // uses the same encoder as the non-bridge path. Bridging
8205        // semantics (what gets bridged, precedence) are unchanged.
8206        assert_eq!(url, "http://x?b=x%20y");
8207        assert!(!url.contains('+'));
8208    }
8209
8210    #[test]
8211    fn bridge_arm_carries_authored_raw_query() {
8212        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
8213        // bridgeEndpoint is consumed as an endpoint option; a=1 is the
8214        // authored leftover riding raw_query.
8215        let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
8216
8217        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8218
8219        // Authored leftovers ride under bridging (Apache Camel semantics):
8220        // query is a=1 in authored bytes; exchange path/query stay ignored.
8221        assert_eq!(url, "http://h/p?a=1");
8222        assert!(!url.contains("dropme"), "exchange query leaked: {url}");
8223        assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
8224    }
8225
8226    // -----------------------------------------------------------------------
8227    // Bridge arm verbatim assembly (Papal Direction A): the bridged base is
8228    // never round-tripped through `url::Url` normalization — authored bytes
8229    // end-to-end, identical assembly to every other resolve_url arm.
8230    // -----------------------------------------------------------------------
8231
8232    #[test]
8233    fn resolve_url_bridge_preserves_dot_segments() {
8234        let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
8235        cfg.bridge_endpoint = true;
8236        cfg.query_params.push(("k".to_string(), "1".to_string()));
8237        let exchange = Exchange::new(Message::default());
8238
8239        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8240
8241        // Dot segments are authored bytes; the old round-trip collapsed
8242        // them (`/a/../b` → `/b`). Verbatim keeps them.
8243        assert_eq!(url, "http://h/a/../b?k=1");
8244    }
8245
8246    #[test]
8247    fn resolve_url_bridge_preserves_default_port() {
8248        let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
8249        cfg.bridge_endpoint = true;
8250        cfg.query_params.push(("k".to_string(), "1".to_string()));
8251        let exchange = Exchange::new(Message::default());
8252
8253        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8254
8255        // The old round-trip stripped the default port `:80`. Verbatim
8256        // keeps it.
8257        assert_eq!(url, "http://h:80/p?k=1");
8258    }
8259
8260    #[test]
8261    fn resolve_url_bridge_preserves_scheme_and_host_case() {
8262        let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
8263        cfg.bridge_endpoint = true;
8264        cfg.query_params.push(("k".to_string(), "1".to_string()));
8265        // `from_uri`'s scheme validation is case-sensitive, so the scheme
8266        // case is applied on the stored base directly — the resolve path
8267        // must carry whatever bytes the operator authored.
8268        cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
8269        let exchange = Exchange::new(Message::default());
8270
8271        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8272
8273        // The old round-trip lowercased scheme and host. Verbatim keeps
8274        // both authored.
8275        assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
8276    }
8277
8278    #[test]
8279    fn resolve_url_bridge_no_query_emits_base_verbatim() {
8280        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8281        cfg.bridge_endpoint = true;
8282        let exchange = Exchange::new(Message::default());
8283
8284        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8285
8286        // No resolved query: exactly the authored base — no synthetic `/`,
8287        // no dangling `?`.
8288        assert_eq!(url, "http://h/p");
8289    }
8290
8291    #[test]
8292    fn resolve_url_bridge_and_non_bridge_byte_identical() {
8293        // (a) Bridged arm: the effective query comes from programmatic
8294        // query_params.
8295        let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
8296        bridged.bridge_endpoint = true;
8297        bridged
8298            .query_params
8299            .push(("k".to_string(), "1".to_string()));
8300        let bridge_url =
8301            HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
8302
8303        // (b) Non-bridge CamelHttpQuery composition path: same effective
8304        // query riding the exchange header.
8305        let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
8306        let mut exchange = Exchange::new(Message::default());
8307        exchange.input.set_header(
8308            "CamelHttpQuery",
8309            serde_json::Value::String("k=1".to_string()),
8310        );
8311        let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
8312
8313        assert_eq!(bridge_url, plain_url);
8314        assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
8315    }
8316
8317    #[test]
8318    fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
8319        let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
8320        cfg.bridge_endpoint = true;
8321        cfg.query_params.push(("k".to_string(), "1".to_string()));
8322        let exchange = Exchange::new(Message::default());
8323
8324        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8325
8326        assert_eq!(url, "http://[::1]:8080/p?k=1");
8327    }
8328
8329    #[test]
8330    fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
8331        let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
8332        let exchange = Exchange::new(Message::default());
8333
8334        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8335
8336        // Authored query on an empty base path: the old round-trip
8337        // inserted a synthetic `/` (`http://h/?x=1`); verbatim does not.
8338        assert_eq!(url, "http://h?x=1");
8339    }
8340
8341    // -----------------------------------------------------------------------
8342    // Raw-preserving outbound query serialization (http-query-wire-fidelity)
8343    // -----------------------------------------------------------------------
8344
8345    #[test]
8346    fn resolve_url_preserves_authored_query_order_and_bytes() {
8347        let config =
8348            HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
8349        let exchange = Exchange::new(Message::default());
8350
8351        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8352
8353        // Authored order, authored separators, no %2C/%3A re-encoding,
8354        // consumed option (connectTimeout) removed.
8355        assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
8356    }
8357
8358    #[test]
8359    fn resolve_url_consumes_encoded_option_key() {
8360        let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
8361        let exchange = Exchange::new(Message::default());
8362
8363        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8364
8365        // The raw filter matches the decoded key, not the encoded bytes.
8366        assert_eq!(url, "http://h/p?a=1");
8367    }
8368
8369    #[test]
8370    fn resolve_url_all_options_consumed_drops_query() {
8371        let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
8372        let exchange = Exchange::new(Message::default());
8373
8374        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8375
8376        // A non-empty query whose every pair was consumed drops the query
8377        // component entirely — no dangling `?`.
8378        assert_eq!(url, "http://h/p");
8379        assert!(!url.contains('?'));
8380    }
8381
8382    #[test]
8383    fn resolve_url_preserves_empty_query_marker() {
8384        let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
8385        let exchange = Exchange::new(Message::default());
8386
8387        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8388
8389        // A bare `?` marker is preserved distinctly, never conflated with
8390        // an all-consumed query.
8391        assert_eq!(url, "http://h/p?");
8392    }
8393
8394    #[test]
8395    fn resolve_url_raw_wrapper_not_re_encoded() {
8396        let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
8397        let exchange = Exchange::new(Message::default());
8398
8399        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8400
8401        // RAW(...) wrapper bytes survive exactly as authored (rc-g4isv).
8402        assert_eq!(url, "http://h/p?token=RAW(abc)");
8403        assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
8404    }
8405
8406    #[test]
8407    fn resolve_url_camel_http_query_composes_verbatim_span() {
8408        let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
8409        let mut exchange = Exchange::new(Message::default());
8410        exchange.input.set_header(
8411            "CamelHttpQuery",
8412            serde_json::Value::String("userFilter=a%2Cb".to_string()),
8413        );
8414
8415        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8416
8417        // Policy change (ADR-0071): the header no longer replaces the
8418        // endpoint query — it composes, the endpoint winning collisions.
8419        // The header span bytes still ride verbatim: `a%2Cb` is carried
8420        // as-authored, never re-encoded (no %252C).
8421        assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
8422        assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
8423    }
8424
8425    // -----------------------------------------------------------------------
8426    // Outbound query composition (http-contract-surface, ADR-0071)
8427    // -----------------------------------------------------------------------
8428
8429    #[test]
8430    fn header_composes_with_endpoint_query() {
8431        let config =
8432            HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
8433        let mut exchange = Exchange::new(Message::default());
8434        exchange.input.set_header(
8435            "CamelHttpQuery",
8436            serde_json::Value::String("lang=es&page=2".to_string()),
8437        );
8438
8439        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8440
8441        // Higher precedence (endpoint) wins collisions: `lang` stays `en`;
8442        // the header appends only its absent keys.
8443        assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
8444    }
8445
8446    #[test]
8447    fn header_alone_still_rides() {
8448        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8449        let mut exchange = Exchange::new(Message::default());
8450        exchange.input.set_header(
8451            "CamelHttpQuery",
8452            serde_json::Value::String("page=2".to_string()),
8453        );
8454
8455        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8456
8457        // No endpoint query: the header pairs are the whole query.
8458        assert_eq!(url, "http://upstream/api?page=2");
8459    }
8460
8461    #[test]
8462    fn empty_reflected_query_leaves_endpoint_query_intact() {
8463        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
8464        let mut exchange = Exchange::new(Message::default());
8465        // The consumer installs an empty CamelHttpQuery on requests that
8466        // arrived without a query string.
8467        exchange
8468            .input
8469            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
8470
8471        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8472
8473        // No second `?` marker, no dropped endpoint pair.
8474        assert_eq!(url, "http://upstream/api?apiKey=secret");
8475        assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
8476    }
8477
8478    #[test]
8479    fn forbidden_byte_in_header_query_errors() {
8480        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8481        let mut exchange = Exchange::new(Message::default());
8482        exchange.input.set_header(
8483            "CamelHttpQuery",
8484            serde_json::Value::String("q=ab<cd".to_string()),
8485        );
8486
8487        let err = HttpProducer::resolve_url(&exchange, &config)
8488            .unwrap_err()
8489            .to_string();
8490
8491        // Fail loud naming the forbidden byte (`<` = 0x3C); a resolve
8492        // error means no URL is emitted, never a re-encoded one.
8493        assert!(err.contains("0x3C"), "error must name the byte: {err}");
8494    }
8495
8496    #[test]
8497    fn override_uri_with_query_plus_header_query() {
8498        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8499        let mut exchange = Exchange::new(Message::default());
8500        exchange.input.set_header(
8501            "CamelHttpUri",
8502            serde_json::Value::String("http://host/api?a=1".to_string()),
8503        );
8504        exchange.input.set_header(
8505            "CamelHttpQuery",
8506            serde_json::Value::String("a=2&b=3".to_string()),
8507        );
8508
8509        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8510
8511        // Pair-level merge with a single `?`: the override's `a=1` wins
8512        // the collision, the header appends `b=3` — no `?a=1?a=2` concat.
8513        assert_eq!(url, "http://host/api?a=1&b=3");
8514    }
8515
8516    #[test]
8517    fn path_applies_before_query_composition() {
8518        let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8519        let mut exchange = Exchange::new(Message::default());
8520        exchange.input.set_header(
8521            "CamelHttpUri",
8522            serde_json::Value::String("http://host/api?a=1".to_string()),
8523        );
8524        exchange.input.set_header(
8525            "CamelHttpPath",
8526            serde_json::Value::String("/extra".to_string()),
8527        );
8528        exchange.input.set_header(
8529            "CamelHttpQuery",
8530            serde_json::Value::String("b=2".to_string()),
8531        );
8532
8533        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8534
8535        // CamelHttpPath applies to the override base without its query,
8536        // then the query composes.
8537        assert_eq!(url, "http://host/api/extra?a=1&b=2");
8538    }
8539
8540    #[test]
8541    fn plain_proxy_reflection_composes() {
8542        let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
8543        // Headers as the consumer installs them from the wire.
8544        let exchange = exchange_with_path_and_query("/in/extra", "page=2");
8545
8546        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8547
8548        // Reflection rides by default and composes: the operator pair is
8549        // not replaced (rc-k3pir parity).
8550        assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
8551    }
8552
8553    #[test]
8554    fn bridge_endpoint_ignores_url_headers() {
8555        let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
8556        let mut exchange = Exchange::new(Message::default());
8557        exchange.input.set_header(
8558            "CamelHttpUri",
8559            serde_json::Value::String("http://evil.test/x".to_string()),
8560        );
8561        exchange.input.set_header(
8562            "CamelHttpPath",
8563            serde_json::Value::String("/foo".to_string()),
8564        );
8565        exchange.input.set_header(
8566            "CamelHttpQuery",
8567            serde_json::Value::String("z=9".to_string()),
8568        );
8569
8570        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8571
8572        // All three URL headers ignored; the endpoint base plus its own
8573        // (consumed-option-filtered) query is sent, exactly as before.
8574        assert_eq!(url, "http://h/p?a=1");
8575        assert!(!url.contains("evil"), "override leaked: {url}");
8576        assert!(!url.contains("z=9"), "header query leaked: {url}");
8577        assert!(!url.contains("/foo"), "header path leaked: {url}");
8578    }
8579
8580    #[test]
8581    fn resolve_url_programmatic_params_use_percent20_deterministic() {
8582        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8583        config.query_params = vec![
8584            ("b".to_string(), "x y".to_string()),
8585            ("a".to_string(), "1".to_string()),
8586        ];
8587        let exchange = Exchange::new(Message::default());
8588
8589        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8590
8591        // Declaration order (not lexical), minimal RFC-3986 encoding,
8592        // `%20` — never `+` — for spaces.
8593        assert_eq!(url, "http://h/p?b=x%20y&a=1");
8594        assert!(!url.contains('+'));
8595    }
8596
8597    #[test]
8598    fn resolve_url_authored_and_programmatic_merge() {
8599        let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
8600        config.query_params = vec![
8601            ("b".to_string(), "2".to_string()),
8602            ("a".to_string(), "9".to_string()),
8603        ];
8604        let exchange = Exchange::new(Message::default());
8605
8606        let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8607
8608        // Programmatic `b` appended (absent from raw); programmatic `a=9`
8609        // ignored (authored key wins); no duplication.
8610        assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
8611    }
8612
8613    #[test]
8614    fn from_uri_no_longer_fills_query_params_from_uri() {
8615        let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
8616
8617        // Authored pairs live in raw_query ONLY (provenance pin).
8618        assert!(
8619            config.query_params.is_empty(),
8620            "query_params is programmatic-only: {:?}",
8621            config.query_params
8622        );
8623        assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
8624    }
8625
8626    #[test]
8627    fn resolve_url_forbidden_raw_byte_errors() {
8628        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8629        config.raw_query = Some("a=x y".to_string());
8630        let exchange = Exchange::new(Message::default());
8631
8632        let err = HttpProducer::resolve_url(&exchange, &config)
8633            .expect_err("literal space in raw query must error");
8634
8635        // The error names the forbidden byte; no output string is produced.
8636        assert!(
8637            err.to_string().contains("0x20"),
8638            "error must name the forbidden byte: {err}"
8639        );
8640    }
8641
8642    /// rc-m4xk1: the override URI's own query is span-validated at resolve
8643    /// time — a forbidden byte in the override arm errors naming the byte,
8644    /// instead of riding verbatim to a reqwest send error.
8645    #[test]
8646    fn resolve_url_override_query_forbidden_byte_errors() {
8647        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8648        let mut exchange = Exchange::new(Message::default());
8649        exchange.input.set_header(
8650            "CamelHttpUri",
8651            serde_json::Value::String("http://h2/p?a=x y".to_string()),
8652        );
8653
8654        let err = HttpProducer::resolve_url(&exchange, &config)
8655            .expect_err("literal space in the override URI's query must error");
8656
8657        assert!(
8658            err.to_string().contains("0x20"),
8659            "error must name the forbidden byte from the override query: {err}"
8660        );
8661    }
8662
8663    /// rc-m4xk1 pin: decoded-key collision — a header pair whose key decodes
8664    /// to a key already present in the higher-precedence query (here
8665    /// `%61=2`, decoding to `a`) is dropped by the shared decoded-key
8666    /// matching; the higher-precedence authored span rides verbatim.
8667    #[test]
8668    fn merge_header_query_decoded_key_collision_drops_header_pair() {
8669        let merged = merge_header_query(Some("a=1"), "%61=2")
8670            .expect("decoded-key collision must not be a parse error");
8671        assert_eq!(
8672            merged.as_deref(),
8673            Some("a=1"),
8674            "the higher-precedence span wins and the colliding header pair is dropped"
8675        );
8676    }
8677
8678    /// rc-m4xk1 pin: duplicate keys within ONE header query are not
8679    /// deduplicated — both spans ride verbatim in authored order.
8680    #[test]
8681    fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
8682        let merged = merge_header_query(None, "k=1&k=2")
8683            .expect("duplicate header keys must not be a parse error");
8684        assert_eq!(
8685            merged.as_deref(),
8686            Some("k=1&k=2"),
8687            "intra-header duplicate keys ride verbatim"
8688        );
8689    }
8690
8691    /// rc-dhkeo: the Debug surface masks userinfo-style bytes in
8692    /// `base_url` and leaves a userinfo-free base untouched, byte-for-byte.
8693    #[test]
8694    fn endpoint_config_debug_masks_base_url_userinfo() {
8695        let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
8696        config.base_url = "http://user:pass@h.example/p".to_string();
8697        let rendered = format!("{config:?}");
8698        assert!(
8699            rendered.contains("***@h.example"),
8700            "userinfo must render masked: {rendered}"
8701        );
8702        assert!(
8703            !rendered.contains("user:pass"),
8704            "no credentials in Debug output: {rendered}"
8705        );
8706
8707        let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
8708        let rendered_plain = format!("{plain:?}");
8709        assert!(
8710            rendered_plain.contains("http://h.example/p"),
8711            "a base without userinfo renders unchanged: {rendered_plain}"
8712        );
8713    }
8714
8715    /// rc-nmupb: authored apostrophe (0x27) is RFC 3986 pchar-legal, but
8716    /// reqwest's WHATWG parser re-encodes it as `%27` in every http/https
8717    /// query — the raw byte can never ride the wire verbatim. Resolve
8718    /// rejects it naming the byte; the authored `%27` escape is the
8719    /// wire-faithful form and rides verbatim.
8720    #[test]
8721    fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
8722        let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8723
8724        config.raw_query = Some("q=it's".to_string());
8725        let exchange = Exchange::new(Message::default());
8726        let err = HttpProducer::resolve_url(&exchange, &config)
8727            .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
8728        assert!(
8729            err.to_string().contains("0x27"),
8730            "error must name the apostrophe byte: {err}"
8731        );
8732
8733        config.raw_query = Some("q=it%27s".to_string());
8734        let url = HttpProducer::resolve_url(&exchange, &config)
8735            .expect("authored %27 escape is wire-legal");
8736        assert!(
8737            url.contains("q=it%27s"),
8738            "the authored escape must ride byte-for-byte: {url}"
8739        );
8740
8741        // The rest of reqwest's WHATWG special-query set shares the same
8742        // rationale and is rejected alongside (`"` and backtick are not
8743        // RFC 3986 query-legal bytes; `<`/`>` likewise).
8744        for &byte in b"\"`<>" {
8745            config.raw_query = Some(format!("k={}x", byte as char));
8746            let err = HttpProducer::resolve_url(&exchange, &config)
8747                .expect_err("WHATWG special-query byte must be rejected");
8748            assert!(
8749                err.to_string().contains(&format!("0x{byte:02X}")),
8750                "error must name byte 0x{byte:02X}: {err}"
8751            );
8752        }
8753    }
8754
8755    #[test]
8756    fn armed_fence_rejects_unknown_host_redacted() {
8757        let cfg = HttpEndpointConfig::from_uri(
8758            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8759        )
8760        .unwrap();
8761        let mut exchange = Exchange::new(Message::default());
8762        exchange.input.set_header(
8763            "CamelHttpUri",
8764            serde_json::Value::String(
8765                "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
8766            ),
8767        );
8768
8769        let err = HttpProducer::resolve_url(&exchange, &cfg)
8770            .expect_err("override host outside the fence must fail resolution");
8771
8772        let message = err.to_string();
8773        assert!(!message.contains("pass"), "userinfo leaked: {message}");
8774        assert!(!message.contains("s3cret"), "query leaked: {message}");
8775    }
8776
8777    #[test]
8778    fn armed_fence_rejects_password_only_userinfo_redacted() {
8779        let cfg = HttpEndpointConfig::from_uri(
8780            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8781        )
8782        .unwrap();
8783        let mut exchange = Exchange::new(Message::default());
8784        exchange.input.set_header(
8785            "CamelHttpUri",
8786            serde_json::Value::String(
8787                "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
8788            ),
8789        );
8790
8791        let err = HttpProducer::resolve_url(&exchange, &cfg)
8792            .expect_err("password-only override outside the fence must fail resolution");
8793
8794        let message = err.to_string();
8795        assert!(
8796            !message.contains("passwordonly"),
8797            "password-only userinfo leaked: {message}"
8798        );
8799        assert!(!message.contains("querysecret"), "query leaked: {message}");
8800        assert!(
8801            message.contains("http://***@evil.example.com/x?[redacted]"),
8802            "masked shape missing: {message}"
8803        );
8804    }
8805
8806    #[test]
8807    fn armed_fence_allows_listed_host() {
8808        let cfg = HttpEndpointConfig::from_uri(
8809            "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8810        )
8811        .unwrap();
8812        let mut exchange = Exchange::new(Message::default());
8813        exchange.input.set_header(
8814            "CamelHttpUri",
8815            serde_json::Value::String("http://cdn.example.com/x".to_string()),
8816        );
8817
8818        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8819        assert_eq!(url, "http://cdn.example.com/x");
8820    }
8821
8822    #[test]
8823    fn host_only_entry_permits_any_port() {
8824        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
8825        let mut exchange = Exchange::new(Message::default());
8826        exchange.input.set_header(
8827            "CamelHttpUri",
8828            serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
8829        );
8830
8831        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8832        assert_eq!(url, "http://cdn.example.com:9443/x");
8833    }
8834
8835    #[test]
8836    fn unarmed_endpoint_unchanged() {
8837        let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8838        let mut exchange = Exchange::new(Message::default());
8839        exchange.input.set_header(
8840            "CamelHttpUri",
8841            serde_json::Value::String("http://any.example.com/path".to_string()),
8842        );
8843
8844        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8845        assert_eq!(url, "http://any.example.com/path");
8846    }
8847
8848    #[test]
8849    fn empty_allowlist_fails_endpoint_creation() {
8850        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
8851    }
8852
8853    #[test]
8854    fn malformed_entry_fails_endpoint_creation() {
8855        assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
8856    }
8857
8858    #[test]
8859    fn fence_entry_with_path_fails_creation() {
8860        // A trailing path is a typo'd entry: silently narrowing it to the
8861        // hostname would widen or skew the fence. Reject loudly.
8862        assert!(
8863            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
8864        );
8865    }
8866
8867    #[test]
8868    fn fence_entry_with_userinfo_fails_creation() {
8869        assert!(
8870            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
8871        );
8872    }
8873
8874    #[test]
8875    fn ipv6_fence_entry_allows_bracketed_host() {
8876        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
8877        // The textual host forms differ; both parse to the same bracketed
8878        // canonical host (`[::1]`) that the entry stores, so both ride.
8879        for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
8880            let mut exchange = Exchange::new(Message::default());
8881            exchange
8882                .input
8883                .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
8884            let url = HttpProducer::resolve_url(&exchange, &cfg)
8885                .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
8886            assert_eq!(url, uri, "bracketed IPv6 override not honored");
8887        }
8888    }
8889
8890    #[test]
8891    fn dns_case_insensitive_fence_match() {
8892        // The entry is stored ASCII-lowercased, so the mixed-case option
8893        // matches the lowercase override host.
8894        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
8895        let mut exchange = Exchange::new(Message::default());
8896        exchange.input.set_header(
8897            "CamelHttpUri",
8898            serde_json::Value::String("http://cdn.example.com/x".to_string()),
8899        );
8900        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8901        assert_eq!(url, "http://cdn.example.com/x");
8902    }
8903
8904    #[test]
8905    fn fence_allowed_override_query_merges_with_header() {
8906        // Fence pass plus full composition: the override URI query is the
8907        // higher-precedence source, the header pair appends.
8908        let cfg =
8909            HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
8910        let mut exchange = Exchange::new(Message::default());
8911        exchange.input.set_header(
8912            "CamelHttpUri",
8913            serde_json::Value::String("http://host.example/api?a=1".to_string()),
8914        );
8915        exchange.input.set_header(
8916            "CamelHttpQuery",
8917            serde_json::Value::String("b=2".to_string()),
8918        );
8919
8920        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8921        assert_eq!(url, "http://host.example/api?a=1&b=2");
8922    }
8923
8924    #[test]
8925    fn empty_header_with_armed_fence_leaves_no_query() {
8926        let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
8927        let mut exchange = Exchange::new(Message::default());
8928        exchange.input.set_header(
8929            "CamelHttpUri",
8930            serde_json::Value::String("http://host.example/api".to_string()),
8931        );
8932        exchange
8933            .input
8934            .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
8935
8936        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8937        assert_eq!(url, "http://host.example/api");
8938        assert!(!url.contains('?'), "query marker leaked: {url}");
8939    }
8940
8941    #[test]
8942    fn fence_option_is_consumed() {
8943        // A raw query on the base URI plus the fence option; no override
8944        // header. The option is consumed at parse time and must never
8945        // appear in the outbound query.
8946        let cfg =
8947            HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
8948        let exchange = Exchange::new(Message::default());
8949
8950        let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8951        assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
8952        assert!(url.contains("x=1"), "authored query lost: {url}");
8953    }
8954
8955    #[tokio::test]
8956    async fn resolve_url_malformed_base_url_errors_no_panic() {
8957        use tower::ServiceExt;
8958
8959        let (url, _handle) = start_test_server().await;
8960        let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
8961        config.allow_internal = true; // test server binds 127.0.0.1
8962        let producer = HttpProducer {
8963            config: Arc::new(config),
8964            client: build_client(&HttpConfig::default(), None),
8965            pinned_cache: Arc::new(PinnedClientCache::new(
8966                PINNED_CLIENT_TTL,
8967                PINNED_CLIENT_MAX_ENTRIES,
8968            )),
8969            http_config: Arc::new(HttpConfig::default()),
8970            runtime: rt(),
8971        };
8972
8973        // First call: malformed base URL propagates as an error through the
8974        // real producer path — no panic, no poisoned state (rc-ph7z2).
8975        let first = producer
8976            .clone()
8977            .oneshot(Exchange::new(Message::default()))
8978            .await;
8979        let err = first.expect_err("malformed base URL must error, not panic");
8980        assert!(
8981            err.to_string().to_lowercase().contains("url"),
8982            "error must name the malformed URL: {err}"
8983        );
8984
8985        // Second call through the SAME producer succeeds — the failure
8986        // left no poisoned state.
8987        let mut exchange = Exchange::new(Message::default());
8988        exchange.input.set_header(
8989            "CamelHttpUri",
8990            serde_json::Value::String(format!("{url}/api")),
8991        );
8992        let response = producer
8993            .oneshot(exchange)
8994            .await
8995            .expect("valid request through same producer must succeed");
8996        let status = response
8997            .input
8998            .header("CamelHttpResponseCode")
8999            .and_then(|v| v.as_u64())
9000            .unwrap();
9001        assert_eq!(status, 200);
9002    }
9003
9004    #[test]
9005    fn resolve_url_bridge_malformed_base_errors_no_panic() {
9006        let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9007        cfg.bridge_endpoint = true;
9008        cfg.query_params.push(("k".to_string(), "1".to_string()));
9009        // `from_uri` rejects the malformed authority, so the base is set on
9010        // the stored config directly (same build shape as the scheme-case
9011        // test). The bridge arm's validation-only parse (rc-ph7z2) must
9012        // surface it as an error — no panic.
9013        cfg.base_url = "http://[::1:bad".to_string();
9014        let exchange = Exchange::new(Message::default());
9015
9016        let err = HttpProducer::resolve_url(&exchange, &cfg)
9017            .expect_err("malformed bridge base URL must error");
9018        assert!(
9019            err.to_string().contains("invalid base URL"),
9020            "error must name the invalid base URL: {err}"
9021        );
9022    }
9023
9024    #[test]
9025    fn test_http_producer_helpers_status_and_size_boundaries() {
9026        assert!(HttpProducer::is_ok_status(200, (200, 299)));
9027        assert!(HttpProducer::is_ok_status(299, (200, 299)));
9028        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
9029        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
9030
9031        assert!(!exceeds_max_response_body(10, 10));
9032        assert!(exceeds_max_response_body(11, 10));
9033    }
9034
9035    // -----------------------------------------------------------------------
9036    // Content-Type inference tests
9037    // -----------------------------------------------------------------------
9038
9039    async fn setup_consumer_on_free_port(
9040        path: &str,
9041    ) -> (
9042        u16,
9043        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
9044        tokio_util::sync::CancellationToken,
9045    ) {
9046        use camel_component_api::ConsumerContext;
9047
9048        // ADR-0070 staged-listener law: bind, KEEP the socket, and stage it
9049        // in the ServerRegistry; the consumer's `get_or_spawn` consumes the
9050        // staged listener, so the port never returns to the ephemeral pool
9051        // between probe and serve (no bind-read-drop race).
9052        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9053        let port = listener.local_addr().unwrap().port();
9054        ServerRegistry::global()
9055            .stage_listener(listener)
9056            .await
9057            .expect("stage consumer test listener");
9058
9059        let consumer_cfg = HttpServerConfig {
9060            scheme: "http".to_string(),
9061            host: "127.0.0.1".to_string(),
9062            port,
9063            path: path.to_string(),
9064            max_request_body: 2 * 1024 * 1024,
9065            max_response_body: 10 * 1024 * 1024,
9066            max_inflight_requests: 1024,
9067            method: None,
9068            tls_config: None,
9069        };
9070        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9071
9072        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9073        let token = tokio_util::sync::CancellationToken::new();
9074        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9075
9076        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9077
9078        // Readiness without a fixed wall-clock sleep: poll the registry
9079        // entry live (1ms backoff, 5s deadline), then yield so the spawned
9080        // `start()` completes route registration (that tail path has no
9081        // pending timers — only the registry lock — so scheduler yields
9082        // order it deterministically behind this loop).
9083        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
9084        while ServerRegistry::global()
9085            .bound_addr("127.0.0.1", port)
9086            .is_none()
9087        {
9088            assert!(
9089                tokio::time::Instant::now() < deadline,
9090                "consumer server did not become ready on port {port}"
9091            );
9092            tokio::time::sleep(std::time::Duration::from_millis(1)).await;
9093        }
9094        for _ in 0..8 {
9095            tokio::task::yield_now().await;
9096        }
9097
9098        (port, rx, token)
9099    }
9100
9101    #[tokio::test]
9102    async fn test_content_type_inferred_for_json_body() {
9103        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
9104
9105        let client = reqwest::Client::new();
9106        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
9107
9108        let (http_result, _) = tokio::join!(send_fut, async {
9109            if let Some(mut envelope) = rx.recv().await {
9110                envelope.exchange.input.body =
9111                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
9112                if let Some(reply_tx) = envelope.reply_tx {
9113                    let _ = reply_tx.send(Ok(envelope.exchange));
9114                }
9115            }
9116        });
9117
9118        let resp = http_result.unwrap();
9119        assert_eq!(resp.status().as_u16(), 200);
9120        let ct = resp
9121            .headers()
9122            .get("content-type")
9123            .expect("Content-Type header should be present");
9124        assert_eq!(ct, "application/json");
9125        let body = resp.text().await.unwrap();
9126        assert_eq!(body, r#"{"message":"hello"}"#);
9127
9128        token.cancel();
9129    }
9130
9131    #[tokio::test]
9132    async fn test_content_type_inferred_for_text_body() {
9133        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
9134
9135        let client = reqwest::Client::new();
9136        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
9137
9138        let (http_result, _) = tokio::join!(send_fut, async {
9139            if let Some(mut envelope) = rx.recv().await {
9140                envelope.exchange.input.body =
9141                    camel_component_api::Body::Text("plain text response".to_string());
9142                if let Some(reply_tx) = envelope.reply_tx {
9143                    let _ = reply_tx.send(Ok(envelope.exchange));
9144                }
9145            }
9146        });
9147
9148        let resp = http_result.unwrap();
9149        assert_eq!(resp.status().as_u16(), 200);
9150        let ct = resp
9151            .headers()
9152            .get("content-type")
9153            .expect("Content-Type header should be present");
9154        assert_eq!(ct, "text/plain; charset=utf-8");
9155        let body = resp.text().await.unwrap();
9156        assert_eq!(body, "plain text response");
9157
9158        token.cancel();
9159    }
9160
9161    #[tokio::test]
9162    async fn test_content_type_inferred_for_xml_body() {
9163        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
9164
9165        let client = reqwest::Client::new();
9166        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
9167
9168        let (http_result, _) = tokio::join!(send_fut, async {
9169            if let Some(mut envelope) = rx.recv().await {
9170                envelope.exchange.input.body =
9171                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
9172                if let Some(reply_tx) = envelope.reply_tx {
9173                    let _ = reply_tx.send(Ok(envelope.exchange));
9174                }
9175            }
9176        });
9177
9178        let resp = http_result.unwrap();
9179        assert_eq!(resp.status().as_u16(), 200);
9180        let ct = resp
9181            .headers()
9182            .get("content-type")
9183            .expect("Content-Type header should be present");
9184        assert_eq!(ct, "application/xml");
9185        let body = resp.text().await.unwrap();
9186        assert_eq!(body, "<root><item>value</item></root>");
9187
9188        token.cancel();
9189    }
9190
9191    #[tokio::test]
9192    async fn test_no_content_type_for_empty_body() {
9193        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
9194
9195        let client = reqwest::Client::new();
9196        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
9197
9198        let (http_result, _) = tokio::join!(send_fut, async {
9199            if let Some(mut envelope) = rx.recv().await {
9200                envelope.exchange.input.body = camel_component_api::Body::Empty;
9201                if let Some(reply_tx) = envelope.reply_tx {
9202                    let _ = reply_tx.send(Ok(envelope.exchange));
9203                }
9204            }
9205        });
9206
9207        let resp = http_result.unwrap();
9208        assert_eq!(resp.status().as_u16(), 200);
9209        assert!(
9210            resp.headers().get("content-type").is_none(),
9211            "Empty body should not set Content-Type"
9212        );
9213
9214        token.cancel();
9215    }
9216
9217    #[tokio::test]
9218    async fn test_no_content_type_for_raw_bytes_body() {
9219        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
9220
9221        let client = reqwest::Client::new();
9222        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
9223
9224        let (http_result, _) = tokio::join!(send_fut, async {
9225            if let Some(mut envelope) = rx.recv().await {
9226                envelope.exchange.input.body =
9227                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
9228                if let Some(reply_tx) = envelope.reply_tx {
9229                    let _ = reply_tx.send(Ok(envelope.exchange));
9230                }
9231            }
9232        });
9233
9234        let resp = http_result.unwrap();
9235        assert_eq!(resp.status().as_u16(), 200);
9236        assert!(
9237            resp.headers().get("content-type").is_none(),
9238            "Raw Bytes body should not set Content-Type"
9239        );
9240
9241        token.cancel();
9242    }
9243
9244    #[tokio::test]
9245    async fn test_content_type_from_stream_metadata() {
9246        use camel_component_api::{StreamBody, StreamMetadata};
9247        use futures::stream;
9248
9249        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
9250
9251        let client = reqwest::Client::new();
9252        let send_fut = client
9253            .get(format!("http://127.0.0.1:{port}/stream-ct"))
9254            .send();
9255
9256        let (http_result, _) = tokio::join!(send_fut, async {
9257            if let Some(mut envelope) = rx.recv().await {
9258                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
9259                    vec![Ok(bytes::Bytes::from("audio data"))];
9260                let stream = Box::pin(stream::iter(chunks));
9261                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
9262                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
9263                    metadata: StreamMetadata {
9264                        size_hint: None,
9265                        content_type: Some("audio/mpeg".to_string()),
9266                        origin: None,
9267                    },
9268                });
9269                if let Some(reply_tx) = envelope.reply_tx {
9270                    let _ = reply_tx.send(Ok(envelope.exchange));
9271                }
9272            }
9273        });
9274
9275        let resp = http_result.unwrap();
9276        assert_eq!(resp.status().as_u16(), 200);
9277        let ct = resp
9278            .headers()
9279            .get("content-type")
9280            .expect("Content-Type header should be present");
9281        assert_eq!(ct, "audio/mpeg");
9282        let body = resp.text().await.unwrap();
9283        assert_eq!(body, "audio data");
9284
9285        token.cancel();
9286    }
9287
9288    #[tokio::test]
9289    async fn test_user_content_type_overrides_inferred() {
9290        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
9291
9292        let client = reqwest::Client::new();
9293        let send_fut = client
9294            .get(format!("http://127.0.0.1:{port}/override-ct"))
9295            .send();
9296
9297        let (http_result, _) = tokio::join!(send_fut, async {
9298            if let Some(mut envelope) = rx.recv().await {
9299                envelope.exchange.input.body =
9300                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
9301                envelope.exchange.input.set_header(
9302                    "Content-Type",
9303                    serde_json::Value::String("text/html".to_string()),
9304                );
9305                if let Some(reply_tx) = envelope.reply_tx {
9306                    let _ = reply_tx.send(Ok(envelope.exchange));
9307                }
9308            }
9309        });
9310
9311        let resp = http_result.unwrap();
9312        assert_eq!(resp.status().as_u16(), 200);
9313        let ct = resp
9314            .headers()
9315            .get("content-type")
9316            .expect("Content-Type header should be present");
9317        assert_eq!(
9318            ct, "text/html",
9319            "User-set Content-Type should take precedence over inferred type"
9320        );
9321
9322        token.cancel();
9323    }
9324
9325    #[tokio::test]
9326    async fn test_user_content_type_with_bytes_body() {
9327        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
9328
9329        let client = reqwest::Client::new();
9330        let send_fut = client
9331            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
9332            .send();
9333
9334        let (http_result, _) = tokio::join!(send_fut, async {
9335            if let Some(mut envelope) = rx.recv().await {
9336                envelope.exchange.input.body =
9337                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
9338                envelope.exchange.input.set_header(
9339                    "Content-Type",
9340                    serde_json::Value::String("application/json".to_string()),
9341                );
9342                if let Some(reply_tx) = envelope.reply_tx {
9343                    let _ = reply_tx.send(Ok(envelope.exchange));
9344                }
9345            }
9346        });
9347
9348        let resp = http_result.unwrap();
9349        assert_eq!(resp.status().as_u16(), 200);
9350        let ct = resp
9351            .headers()
9352            .get("content-type")
9353            .expect("Content-Type header should be present for Bytes body with user header");
9354        assert_eq!(
9355            ct, "application/json",
9356            "User Content-Type should be sent for Bytes body"
9357        );
9358
9359        token.cancel();
9360    }
9361
9362    // -----------------------------------------------------------------------
9363    // Server monitor tests (GRL-005)
9364    // -----------------------------------------------------------------------
9365
9366    #[tokio::test]
9367    async fn monitor_task_silent_on_clean_exit() {
9368        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
9369        // Clean exit should complete without panicking or logging errors
9370        monitor_axum_task(
9371            handle,
9372            "127.0.0.1:0".to_string(),
9373            noop_rt(),
9374            "test-monitor".into(),
9375        )
9376        .await;
9377    }
9378
9379    #[tokio::test]
9380    async fn monitor_task_handles_panicked_task() {
9381        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
9382            panic!("simulated server crash");
9383        });
9384        // Should complete without panicking even though the inner task panicked
9385        monitor_axum_task(
9386            handle,
9387            "127.0.0.1:9999".to_string(),
9388            noop_rt(),
9389            "test-monitor".into(),
9390        )
9391        .await;
9392    }
9393
9394    // -----------------------------------------------------------------------
9395    // Credential redaction tests
9396    // -----------------------------------------------------------------------
9397
9398    #[test]
9399    fn http_auth_basic_debug_redacts_password() {
9400        let auth = HttpAuth::Basic {
9401            username: "admin".to_string(),
9402            password: "hunter2".to_string(),
9403        };
9404        let debug = format!("{:?}", auth);
9405        assert!(
9406            !debug.contains("hunter2"),
9407            "password must be redacted: {debug}"
9408        );
9409        assert!(debug.contains("admin"), "username should appear: {debug}");
9410    }
9411
9412    #[test]
9413    fn http_auth_bearer_debug_redacts_token() {
9414        let auth = HttpAuth::Bearer {
9415            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
9416        };
9417        let debug = format!("{:?}", auth);
9418        assert!(
9419            !debug.contains("eyJhbGci"),
9420            "token must be redacted: {debug}"
9421        );
9422    }
9423
9424    #[test]
9425    fn http_auth_none_debug_shows_variant() {
9426        let debug = format!("{:?}", HttpAuth::None);
9427        assert!(
9428            debug.contains("None"),
9429            "None variant should appear: {debug}"
9430        );
9431    }
9432
9433    #[test]
9434    fn http_endpoint_config_debug_redacts_auth_credentials() {
9435        let config = HttpEndpointConfig::from_uri(
9436            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
9437        )
9438        .unwrap();
9439        let debug = format!("{:?}", config);
9440        assert!(
9441            !debug.contains("secret123"),
9442            "password must be redacted in HttpEndpointConfig debug: {debug}"
9443        );
9444    }
9445
9446    #[test]
9447    fn debug_lists_all_public_fields() {
9448        let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9449        let debug = format!("{:?}", config);
9450        for field in [
9451            "base_url",
9452            "http_method",
9453            "throw_exception_on_failure",
9454            "ok_status_code_range",
9455            "response_timeout",
9456            "query_params",
9457            "raw_query",
9458            "allow_internal",
9459            "blocked_hosts",
9460            "max_body_size",
9461            "read_timeout_ms",
9462            "max_response_bytes",
9463            "auth",
9464            "token_provider",
9465            "user_agent",
9466            "bridge_endpoint",
9467            "connection_close",
9468            "skip_request_headers",
9469            "skip_response_headers",
9470            "follow_redirects",
9471            "max_redirects",
9472        ] {
9473            assert!(
9474                debug.contains(field),
9475                "Debug output missing field '{field}': {debug}"
9476            );
9477        }
9478    }
9479
9480    // -----------------------------------------------------------------------
9481    // Static file serving tests (Task 5)
9482    // -----------------------------------------------------------------------
9483
9484    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
9485    use tower_http::services::ServeDir;
9486
9487    fn make_test_registry() -> HttpRouteRegistry {
9488        HttpRouteRegistry::new()
9489    }
9490
9491    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
9492        AppState {
9493            registry,
9494            max_request_body: 2 * 1024 * 1024,
9495            max_response_body: 10 * 1024 * 1024,
9496            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
9497        }
9498    }
9499
9500    #[allow(clippy::await_holding_lock)]
9501    #[tokio::test]
9502    async fn test_static_file_serving_serves_file_contents() {
9503        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9504        ServerRegistry::reset();
9505
9506        // Create temp dir with test files
9507        let temp_dir =
9508            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
9509        std::fs::create_dir_all(&temp_dir).unwrap();
9510        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
9511        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
9512
9513        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9514
9515        let registry = make_test_registry();
9516        let serve_dir = ServeDir::new(&canonical_dir)
9517            .precompressed_gzip()
9518            .precompressed_br()
9519            .append_index_html_on_directories(true);
9520
9521        let mount = StaticMount {
9522            mount_path: "/".to_string(),
9523            mode: MountMode::Static,
9524            dir: canonical_dir.clone(),
9525            cache_control: "public, max-age=3600".to_string(),
9526            error_pages: std::collections::HashMap::new(),
9527            serve_dir,
9528        };
9529        registry.register_static_mount(mount).await.unwrap();
9530
9531        let state = make_test_state(registry);
9532
9533        // Test serving hello.txt
9534        let req = Request::builder()
9535            .uri("/hello.txt")
9536            .body(AxumBody::empty())
9537            .unwrap();
9538        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
9539        assert_eq!(resp.status(), StatusCode::OK);
9540        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9541            .await
9542            .unwrap();
9543        assert_eq!(&body[..], b"Hello, static world!");
9544
9545        // Test serving style.css
9546        let req = Request::builder()
9547            .uri("/style.css")
9548            .body(AxumBody::empty())
9549            .unwrap();
9550        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
9551        assert_eq!(resp.status(), StatusCode::OK);
9552        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9553            .await
9554            .unwrap();
9555        assert_eq!(&body[..], b"body { color: red; }");
9556
9557        // Test 404 for non-existent file
9558        let req = Request::builder()
9559            .uri("/missing.txt")
9560            .body(AxumBody::empty())
9561            .unwrap();
9562        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
9563        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9564
9565        // Cleanup
9566        std::fs::remove_dir_all(&temp_dir).ok();
9567    }
9568
9569    #[allow(clippy::await_holding_lock)]
9570    #[tokio::test]
9571    async fn test_spa_fallback_serves_index_for_unknown_paths() {
9572        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9573        ServerRegistry::reset();
9574
9575        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
9576        std::fs::create_dir_all(&temp_dir).unwrap();
9577        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
9578        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
9579
9580        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9581
9582        let registry = make_test_registry();
9583        let serve_dir = ServeDir::new(&canonical_dir)
9584            .precompressed_gzip()
9585            .precompressed_br()
9586            .append_index_html_on_directories(true);
9587
9588        let mount = StaticMount {
9589            mount_path: "/".to_string(),
9590            mode: MountMode::Spa,
9591            dir: canonical_dir.clone(),
9592            cache_control: "public, max-age=0".to_string(),
9593            error_pages: std::collections::HashMap::new(),
9594            serve_dir,
9595        };
9596        // Register as SPA mount
9597        registry.register_static_mount(mount).await.unwrap();
9598
9599        let state = make_test_state(registry);
9600
9601        // SPA fallback: GET /dashboard with Accept: text/html → index.html
9602        let req = Request::builder()
9603            .method("GET")
9604            .uri("/dashboard")
9605            .header("Accept", "text/html")
9606            .body(AxumBody::empty())
9607            .unwrap();
9608        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
9609        assert_eq!(resp.status(), StatusCode::OK);
9610        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9611            .await
9612            .unwrap();
9613        assert_eq!(&body[..], b"<h1>SPA App</h1>");
9614
9615        // Static file still works: GET /app.js
9616        let req = Request::builder()
9617            .method("GET")
9618            .uri("/app.js")
9619            .body(AxumBody::empty())
9620            .unwrap();
9621        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
9622        assert_eq!(resp.status(), StatusCode::OK);
9623        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9624            .await
9625            .unwrap();
9626        assert_eq!(&body[..], b"console.log('app')");
9627
9628        // No SPA fallback for JSON accept → 404
9629        let req = Request::builder()
9630            .method("GET")
9631            .uri("/api/data")
9632            .header("Accept", "application/json")
9633            .body(AxumBody::empty())
9634            .unwrap();
9635        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
9636        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9637
9638        // No SPA fallback for file extensions → 404
9639        let req = Request::builder()
9640            .method("GET")
9641            .uri("/style.css")
9642            .header("Accept", "text/html")
9643            .body(AxumBody::empty())
9644            .unwrap();
9645        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
9646        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9647
9648        // Cleanup
9649        std::fs::remove_dir_all(&temp_dir).ok();
9650    }
9651
9652    // Regression for rc-zoai: a conditional GET (If-None-Match / If-Modified-Since)
9653    // whose validator matches MUST return 304 Not Modified, not 404. The bug was
9654    // dispatch_static's L92 gate `if resp.status().is_success()` discarding
9655    // ServeDir's legitimate 304 and falling through to the generic 404. The fix
9656    // adds `|| resp.status() == StatusCode::NOT_MODIFIED` to that gate (and the
9657    // matching gate in serve_via_serve_dir so the 304 keeps its Cache-Control).
9658    #[allow(clippy::await_holding_lock)]
9659    async fn run_conditional_get_returns_304(mode: MountMode) {
9660        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9661        ServerRegistry::reset();
9662
9663        let temp_dir = std::env::temp_dir().join(format!(
9664            "http_cond_get_{}_{}",
9665            if mode == MountMode::Spa {
9666                "spa"
9667            } else {
9668                "static"
9669            },
9670            std::process::id()
9671        ));
9672        std::fs::create_dir_all(&temp_dir).unwrap();
9673        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
9674
9675        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9676
9677        let registry = make_test_registry();
9678        let serve_dir = ServeDir::new(&canonical_dir)
9679            .precompressed_gzip()
9680            .precompressed_br()
9681            .append_index_html_on_directories(true);
9682
9683        let mount = StaticMount {
9684            mount_path: "/".to_string(),
9685            mode,
9686            dir: canonical_dir.clone(),
9687            cache_control: "public, max-age=3600".to_string(),
9688            error_pages: std::collections::HashMap::new(),
9689            serve_dir,
9690        };
9691        registry.register_static_mount(mount).await.unwrap();
9692
9693        let state = make_test_state(registry);
9694
9695        // 1st request: normal GET → 200, capture validators.
9696        let req = Request::builder()
9697            .method("GET")
9698            .uri("/index.html")
9699            .body(AxumBody::empty())
9700            .unwrap();
9701        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9702        assert_eq!(
9703            resp.status(),
9704            StatusCode::OK,
9705            "first GET should return 200, got {}",
9706            resp.status()
9707        );
9708        // Cache-Control must be attached on 200 (sanity for serve_via_serve_dir).
9709        assert!(
9710            resp.headers().contains_key(http::header::CACHE_CONTROL),
9711            "200 response missing Cache-Control"
9712        );
9713        let etag = resp
9714            .headers()
9715            .get(http::header::ETAG)
9716            .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
9717            .clone();
9718        let last_modified = resp
9719            .headers()
9720            .get(http::header::LAST_MODIFIED)
9721            .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
9722            .clone();
9723        // Consume the body so the response is fully drained.
9724        let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
9725            .await
9726            .unwrap();
9727
9728        // 2nd request: If-None-Match with the captured ETag → 304.
9729        // Unconditional: ETag presence is required (asserted above) so this
9730        // sub-test cannot silently skip on a ServeDir etag_method change.
9731        let req = Request::builder()
9732            .method("GET")
9733            .uri("/index.html")
9734            .header(http::header::IF_NONE_MATCH, etag.clone())
9735            .body(AxumBody::empty())
9736            .unwrap();
9737        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9738        assert_eq!(
9739            resp.status(),
9740            StatusCode::NOT_MODIFIED,
9741            "If-None-Match with matching ETag should return 304, got {}",
9742            resp.status()
9743        );
9744        // RFC 7232 §4.1: 304 SHOULD include Cache-Control (the serve_via_serve_dir fix).
9745        assert!(
9746            resp.headers().contains_key(http::header::CACHE_CONTROL),
9747            "304 (If-None-Match) missing Cache-Control"
9748        );
9749        // RFC 7232 §4.1: 304 SHOULD carry the validators forward. Assert the
9750        // response parts rebuild in serve_via_serve_dir preserves them.
9751        assert_eq!(
9752            resp.headers().get(http::header::ETAG),
9753            Some(&etag),
9754            "304 (If-None-Match) must echo the ETag validator"
9755        );
9756        assert_eq!(
9757            resp.headers().get(http::header::LAST_MODIFIED),
9758            Some(&last_modified),
9759            "304 (If-None-Match) must carry Last-Modified"
9760        );
9761
9762        // 3rd request: If-Modified-Since with the captured Last-Modified → 304.
9763        let req = Request::builder()
9764            .method("GET")
9765            .uri("/index.html")
9766            .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
9767            .body(AxumBody::empty())
9768            .unwrap();
9769        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9770        assert_eq!(
9771            resp.status(),
9772            StatusCode::NOT_MODIFIED,
9773            "If-Modified-Since with matching timestamp should return 304, got {}",
9774            resp.status()
9775        );
9776        assert!(
9777            resp.headers().contains_key(http::header::CACHE_CONTROL),
9778            "304 (If-Modified-Since) missing Cache-Control"
9779        );
9780        assert_eq!(
9781            resp.headers().get(http::header::ETAG),
9782            Some(&etag),
9783            "304 (If-Modified-Since) must carry the ETag validator"
9784        );
9785        assert_eq!(
9786            resp.headers().get(http::header::LAST_MODIFIED),
9787            Some(&last_modified),
9788            "304 (If-Modified-Since) must echo Last-Modified"
9789        );
9790
9791        // Negative control: a PAST If-Modified-Since (before the file's mtime)
9792        // MUST return 200 — proving the 304 path is validator-aware, not a
9793        // blanket "always 304" regression. A future date would correctly yield
9794        // 304 since the file's mtime precedes it; that is RFC-correct 304
9795        // behaviour, not a negative control.
9796        let req = Request::builder()
9797            .method("GET")
9798            .uri("/index.html")
9799            .header(
9800                http::header::IF_MODIFIED_SINCE,
9801                "Wed, 21 Oct 2000 07:28:00 GMT",
9802            )
9803            .body(AxumBody::empty())
9804            .unwrap();
9805        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9806        assert_eq!(
9807            resp.status(),
9808            StatusCode::OK,
9809            "past If-Modified-Since should return 200 (file modified after it), got {}",
9810            resp.status()
9811        );
9812
9813        // Cleanup
9814        std::fs::remove_dir_all(&temp_dir).ok();
9815    }
9816
9817    #[tokio::test]
9818    async fn test_conditional_get_returns_304_static_mode() {
9819        run_conditional_get_returns_304(MountMode::Static).await;
9820    }
9821
9822    #[tokio::test]
9823    async fn test_conditional_get_returns_304_spa_mode() {
9824        run_conditional_get_returns_304(MountMode::Spa).await;
9825    }
9826
9827    #[allow(clippy::await_holding_lock)]
9828    #[tokio::test]
9829    async fn test_error_page_mapping_serves_custom_404() {
9830        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9831        ServerRegistry::reset();
9832
9833        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
9834        let errors_dir = temp_dir.join("errors");
9835        std::fs::create_dir_all(&errors_dir).unwrap();
9836        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
9837        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
9838
9839        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9840        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
9841
9842        let registry = make_test_registry();
9843        let serve_dir = ServeDir::new(&canonical_dir)
9844            .precompressed_gzip()
9845            .precompressed_br()
9846            .append_index_html_on_directories(true);
9847
9848        let mut error_pages = std::collections::HashMap::new();
9849        error_pages.insert(404, canonical_404);
9850
9851        let mount = StaticMount {
9852            mount_path: "/".to_string(),
9853            mode: MountMode::Static,
9854            dir: canonical_dir.clone(),
9855            cache_control: "public, max-age=0".to_string(),
9856            error_pages,
9857            serve_dir,
9858        };
9859        registry.register_static_mount(mount).await.unwrap();
9860
9861        let state = make_test_state(registry);
9862
9863        // Request non-existent file → custom 404 page
9864        let req = Request::builder()
9865            .method("GET")
9866            .uri("/missing.html")
9867            .body(AxumBody::empty())
9868            .unwrap();
9869        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
9870        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9871        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9872            .await
9873            .unwrap();
9874        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
9875
9876        // Existing file still works
9877        let req = Request::builder()
9878            .method("GET")
9879            .uri("/index.html")
9880            .body(AxumBody::empty())
9881            .unwrap();
9882        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9883        assert_eq!(resp.status(), StatusCode::OK);
9884        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9885            .await
9886            .unwrap();
9887        assert_eq!(&body[..], b"<h1>Home</h1>");
9888
9889        // Cleanup
9890        std::fs::remove_dir_all(&temp_dir).ok();
9891    }
9892
9893    #[tokio::test]
9894    async fn http_consumer_returns_body_and_code_on_stop() {
9895        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
9896        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
9897        use tower::ServiceExt;
9898
9899        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
9900        let set_body_step = CompiledStep::Process {
9901            kind_hint: camel_api::SpanKindHint::Internal,
9902            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
9903                ex.input.body = Body::Text("nope".into());
9904                Box::pin(async move { Ok(ex) })
9905            }),
9906            body_contract: None,
9907            lifecycle: None,
9908            label: None,
9909        };
9910        let set_status_step = CompiledStep::Process {
9911            kind_hint: camel_api::SpanKindHint::Internal,
9912            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
9913                ex.input.set_header(
9914                    "CamelHttpResponseCode",
9915                    serde_json::Value::Number(409.into()),
9916                );
9917                Box::pin(async move { Ok(ex) })
9918            }),
9919            body_contract: None,
9920            lifecycle: None,
9921            label: None,
9922        };
9923        let pipeline = compose_pipeline_with_handler(
9924            vec![set_body_step, set_status_step, CompiledStep::Stop],
9925            None,
9926            PipelineRuntimeCtx::compile_time(),
9927        );
9928
9929        let ex = Exchange::new(Message::default());
9930        let result = pipeline.oneshot(ex).await;
9931        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
9932        let returned = result.unwrap();
9933        assert_eq!(returned.input.body.as_text(), Some("nope"));
9934        assert_eq!(
9935            returned
9936                .input
9937                .header("CamelHttpResponseCode")
9938                .and_then(|v| v.as_u64()),
9939            Some(409)
9940        );
9941    }
9942
9943    #[tokio::test]
9944    async fn http_consumer_returns_200_when_body_empty_on_stop() {
9945        // After ADR-0024: Stop with no body + no status header produces 200 (same as
9946        // a normal completion with no body). The 204 default is gone — users who
9947        // want 204 set CamelHttpResponseCode=204 explicitly.
9948        //
9949        // This test stays at the pipeline level (consistent with the test above).
9950        // E2E coverage of the full HTTP dispatch path is in
9951        // crates/camel-test/tests/integration_test.rs.
9952        use camel_api::{Exchange, Message};
9953        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
9954        use tower::ServiceExt;
9955
9956        let pipeline = compose_pipeline_with_handler(
9957            vec![CompiledStep::Stop],
9958            None,
9959            PipelineRuntimeCtx::compile_time(),
9960        );
9961        let ex = Exchange::new(Message::default());
9962        let result = pipeline.oneshot(ex).await;
9963        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
9964        // Body is default (empty); no CamelHttpResponseCode header was set.
9965        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
9966    }
9967
9968    // -----------------------------------------------------------------------
9969    // Task 5: Method-aware REST dispatch tests
9970    // -----------------------------------------------------------------------
9971
9972    /// Spins up an axum server on a free port with a fresh registry.
9973    /// Returns the port plus the registry so the caller can register
9974    /// REST endpoints directly.
9975    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
9976        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9977        let port = listener.local_addr().unwrap().port();
9978        let registry = HttpRouteRegistry::new();
9979        tokio::spawn(run_axum_server(
9980            listener,
9981            registry.clone(),
9982            2 * 1024 * 1024,
9983            10 * 1024 * 1024,
9984            Arc::new(tokio::sync::Semaphore::new(1024)),
9985            test_rt(),
9986            "test-route".into(),
9987        ));
9988        // Give the server a moment to start accepting.
9989        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9990        (port, registry)
9991    }
9992
9993    /// Helper for REST integration tests: spawns a responder task that
9994    /// reads from `rx`, writes a fixed `(status, body)` back via the
9995    /// envelope's reply channel, and returns once the test request is
9996    /// satisfied.
9997    fn spawn_responder(
9998        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
9999        status: u16,
10000        body: String,
10001    ) -> tokio::task::JoinHandle<()> {
10002        tokio::spawn(async move {
10003            if let Some(envelope) = rx.recv().await {
10004                let _ = envelope.reply_tx.send(HttpReply {
10005                    status,
10006                    headers: vec![],
10007                    body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
10008                });
10009            }
10010        })
10011    }
10012
10013    #[tokio::test]
10014    async fn method_aware_dispatch_same_path_different_verbs() {
10015        let (port, registry) = spawn_test_server().await;
10016
10017        // Register two REST endpoints on the same path with different
10018        // methods. This is the core scenario REST DSL needs to support:
10019        // GET /users (list) and POST /users (create) must not overwrite
10020        // each other.
10021        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10022        registry
10023            .register_rest_endpoint(
10024                "GET".into(),
10025                vec![PathSegment::Literal("users".into())],
10026                get_tx,
10027            )
10028            .await;
10029
10030        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10031        registry
10032            .register_rest_endpoint(
10033                "POST".into(),
10034                vec![PathSegment::Literal("users".into())],
10035                post_tx,
10036            )
10037            .await;
10038
10039        let get_handle = spawn_responder(get_rx, 200, "list".into());
10040        let post_handle = spawn_responder(post_rx, 201, "create".into());
10041
10042        let client = reqwest::Client::new();
10043
10044        // GET /users → list route
10045        let resp = client
10046            .get(format!("http://127.0.0.1:{port}/users"))
10047            .send()
10048            .await
10049            .unwrap();
10050        assert_eq!(resp.status().as_u16(), 200);
10051        let body = resp.text().await.unwrap();
10052        assert_eq!(body, "list");
10053
10054        // POST /users → create route
10055        let resp = client
10056            .post(format!("http://127.0.0.1:{port}/users"))
10057            .send()
10058            .await
10059            .unwrap();
10060        assert_eq!(resp.status().as_u16(), 201);
10061        let body = resp.text().await.unwrap();
10062        assert_eq!(body, "create");
10063
10064        let _ = tokio::join!(get_handle, post_handle);
10065    }
10066
10067    #[tokio::test]
10068    async fn method_aware_dispatch_templated_path_extracts_params() {
10069        let (port, registry) = spawn_test_server().await;
10070
10071        // Register GET /users/{id} as a templated endpoint. The
10072        // dispatcher should match `/users/42` against the template and
10073        // attach `id=42` to the envelope's path_params.
10074        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10075        registry
10076            .register_rest_endpoint(
10077                "GET".into(),
10078                vec![
10079                    PathSegment::Literal("users".into()),
10080                    PathSegment::Param("id".into()),
10081                ],
10082                tx,
10083            )
10084            .await;
10085
10086        // Spawn a responder that echoes the captured id back in the body
10087        // so the test can verify the param was set.
10088        let handle = tokio::spawn(async move {
10089            if let Some(envelope) = rx.recv().await {
10090                let id = envelope.path_params.get("id").cloned().unwrap_or_default();
10091                let _ = envelope.reply_tx.send(HttpReply {
10092                    status: 200,
10093                    headers: vec![],
10094                    body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
10095                });
10096            }
10097        });
10098
10099        let client = reqwest::Client::new();
10100        let resp = client
10101            .get(format!("http://127.0.0.1:{port}/users/42"))
10102            .send()
10103            .await
10104            .unwrap();
10105        assert_eq!(resp.status().as_u16(), 200);
10106        let body = resp.text().await.unwrap();
10107        assert_eq!(body, "id=42");
10108
10109        let _ = handle.await;
10110    }
10111
10112    #[tokio::test]
10113    async fn method_aware_dispatch_unmatched_method_falls_through() {
10114        // If no REST endpoint matches the method, dispatch must fall
10115        // through to the legacy api_routes lookup or static mounts. With
10116        // nothing else registered, the request gets 404 from static
10117        // dispatch.
10118        let (port, _registry) = spawn_test_server().await;
10119
10120        // Register only GET /users; a DELETE /users request has no match.
10121        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10122        _registry
10123            .register_rest_endpoint(
10124                "GET".into(),
10125                vec![PathSegment::Literal("users".into())],
10126                get_tx,
10127            )
10128            .await;
10129
10130        // Drain the GET channel in the background so the consumer side
10131        // doesn't block (we don't expect any envelopes here).
10132        let drain = tokio::spawn(async move {
10133            let mut get_rx = get_rx;
10134            while get_rx.recv().await.is_some() {}
10135        });
10136
10137        let client = reqwest::Client::new();
10138        let resp = client
10139            .delete(format!("http://127.0.0.1:{port}/users"))
10140            .send()
10141            .await
10142            .unwrap();
10143        assert_eq!(resp.status().as_u16(), 404);
10144
10145        drop(drain);
10146    }
10147
10148    #[tokio::test]
10149    async fn regression_legacy_exact_api_route_still_works() {
10150        // A `http:` route registered without an `httpMethod=` URI param
10151        // lands in the legacy api_routes registry. The dispatcher must
10152        // still find it via exact path lookup. This guards against
10153        // regressions introduced by the new REST-aware dispatch.
10154        let (port, registry) = spawn_test_server().await;
10155
10156        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10157        registry.register_api_route("/legacy/path".into(), tx).await;
10158
10159        let handle = tokio::spawn(async move {
10160            if let Some(envelope) = rx.recv().await {
10161                let _ = envelope.reply_tx.send(HttpReply {
10162                    status: 200,
10163                    headers: vec![],
10164                    body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
10165                });
10166            }
10167        });
10168
10169        let client = reqwest::Client::new();
10170        let resp = client
10171            .get(format!("http://127.0.0.1:{port}/legacy/path"))
10172            .send()
10173            .await
10174            .unwrap();
10175        assert_eq!(resp.status().as_u16(), 200);
10176        let body = resp.text().await.unwrap();
10177        assert_eq!(body, "legacy ok");
10178
10179        let _ = handle.await;
10180    }
10181
10182    #[allow(clippy::await_holding_lock)]
10183    #[tokio::test]
10184    async fn regression_static_mount_still_works() {
10185        // Verify that static file serving still works after the
10186        // dispatch refactor. We register a temp-dir mount and request
10187        // a file from it; the static dispatcher should serve it.
10188        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
10189        ServerRegistry::reset();
10190
10191        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
10192        std::fs::create_dir_all(&temp_dir).unwrap();
10193        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
10194        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10195
10196        let registry = make_test_registry();
10197        let serve_dir = ServeDir::new(&canonical_dir)
10198            .precompressed_gzip()
10199            .precompressed_br()
10200            .append_index_html_on_directories(true);
10201        let mount = StaticMount {
10202            mount_path: "/".to_string(),
10203            mode: MountMode::Static,
10204            dir: canonical_dir.clone(),
10205            cache_control: "public, max-age=3600".to_string(),
10206            error_pages: std::collections::HashMap::new(),
10207            serve_dir,
10208        };
10209        registry.register_static_mount(mount).await.unwrap();
10210
10211        let state = make_test_state(registry);
10212        let req = Request::builder()
10213            .uri("/regress.txt")
10214            .body(AxumBody::empty())
10215            .unwrap();
10216        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
10217        assert_eq!(resp.status(), StatusCode::OK);
10218        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10219            .await
10220            .unwrap();
10221        assert_eq!(&body[..], b"static works");
10222
10223        std::fs::remove_dir_all(&temp_dir).ok();
10224    }
10225
10226    // -----------------------------------------------------------------------
10227    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
10228    // templated from-URI round-trip. These exercise the real axum dispatch
10229    // path (register → HTTP request → reply) so a regression in any of the
10230    // three critical fixes surfaces as a test failure rather than a silent
10231    // production 404/500.
10232    // -----------------------------------------------------------------------
10233
10234    #[tokio::test]
10235    async fn deregister_one_method_keeps_sibling_verbs() {
10236        // Review C1: stopping the GET /users consumer must NOT tear down the
10237        // live POST /users endpoint. Register both, deregister GET only,
10238        // then verify POST still dispatches.
10239        let (port, registry) = spawn_test_server().await;
10240
10241        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10242        registry
10243            .register_rest_endpoint(
10244                "GET".into(),
10245                vec![PathSegment::Literal("users".into())],
10246                get_tx,
10247            )
10248            .await;
10249
10250        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10251        registry
10252            .register_rest_endpoint(
10253                "POST".into(),
10254                vec![PathSegment::Literal("users".into())],
10255                post_tx,
10256            )
10257            .await;
10258
10259        // Drain GET in the background (no requests expected after deregister).
10260        let drain = tokio::spawn(async move {
10261            let mut get_rx = get_rx;
10262            while get_rx.recv().await.is_some() {}
10263        });
10264
10265        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
10266        registry.unregister_rest_endpoint("GET", "/users").await;
10267        drop(drain);
10268
10269        let post_handle = spawn_responder(post_rx, 201, "create".into());
10270
10271        let client = reqwest::Client::new();
10272        // POST /users must still reach its consumer after GET was removed.
10273        let resp = client
10274            .post(format!("http://127.0.0.1:{port}/users"))
10275            .send()
10276            .await
10277            .unwrap();
10278        assert_eq!(resp.status().as_u16(), 201);
10279        assert_eq!(resp.text().await.unwrap(), "create");
10280
10281        let _ = post_handle.await;
10282    }
10283
10284    #[tokio::test]
10285    async fn dispatch_exact_legacy_beats_rest_template() {
10286        // Review C2: an exact legacy API route (`GET /api/users`, no
10287        // httpMethod) must win over a templated REST route
10288        // (`GET /api/{resource}`) for the request `/api/users`, per spec
10289        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
10290        let (port, registry) = spawn_test_server().await;
10291
10292        // Exact legacy route.
10293        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10294        registry
10295            .register_api_route("/api/users".into(), exact_tx)
10296            .await;
10297        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
10298
10299        // Templated REST route that would ALSO match /api/users.
10300        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10301        registry
10302            .register_rest_endpoint(
10303                "GET".into(),
10304                vec![
10305                    PathSegment::Literal("api".into()),
10306                    PathSegment::Param("resource".into()),
10307                ],
10308                tpl_tx,
10309            )
10310            .await;
10311        // The templated handler must NOT receive the /api/users request. If
10312        // it does, it replies "template-leak" so a future assertion could
10313        // catch it. We do NOT await this task: the exact-match branch wins
10314        // and the templated channel never receives, so awaiting would block
10315        // until the test runtime tears down.
10316        let _tpl_drain = tokio::spawn(async move {
10317            let mut tpl_rx = tpl_rx;
10318            if let Some(env) = tpl_rx.recv().await {
10319                let _ = env.reply_tx.send(HttpReply {
10320                    status: 200,
10321                    headers: vec![],
10322                    body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
10323                });
10324            }
10325        });
10326
10327        let client = reqwest::Client::new();
10328        let resp = client
10329            .get(format!("http://127.0.0.1:{port}/api/users"))
10330            .send()
10331            .await
10332            .unwrap();
10333        assert_eq!(resp.status().as_u16(), 200);
10334        // Exact-match handler answered — not the templated one.
10335        assert_eq!(resp.text().await.unwrap(), "exact");
10336
10337        let _ = exact_handle.await;
10338    }
10339
10340    #[tokio::test]
10341    async fn ambiguous_rest_templates_return_500_not_silent_404() {
10342        // Review C3: two equal-specificity templates that both match one
10343        // request are an ambiguous registration. At runtime this must
10344        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
10345        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
10346        let (port, registry) = spawn_test_server().await;
10347
10348        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10349        registry
10350            .register_rest_endpoint(
10351                "GET".into(),
10352                vec![
10353                    PathSegment::Literal("users".into()),
10354                    PathSegment::Param("id".into()),
10355                ],
10356                a_tx,
10357            )
10358            .await;
10359
10360        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10361        registry
10362            .register_rest_endpoint(
10363                "GET".into(),
10364                vec![
10365                    PathSegment::Literal("users".into()),
10366                    PathSegment::Param("name".into()),
10367                ],
10368                b_tx,
10369            )
10370            .await;
10371
10372        let client = reqwest::Client::new();
10373        let resp = client
10374            .get(format!("http://127.0.0.1:{port}/users/42"))
10375            .send()
10376            .await
10377            .unwrap();
10378        // Ambiguous → 500 (previously a silent 404).
10379        assert_eq!(resp.status().as_u16(), 500);
10380    }
10381
10382    #[test]
10383    fn from_uri_round_trips_templated_path_with_http_method() {
10384        // Review I4: a REST-lowered from-URI like
10385        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
10386        // through HttpServerConfig::from_uri, preserving the templated path
10387        // and the (uppercased) method. This is the binding the DSL lowering
10388        // emits and the consumer reads; it was previously unasserted.
10389        use crate::UriConfig;
10390        let cfg =
10391            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
10392        assert_eq!(cfg.host, "0.0.0.0");
10393        assert_eq!(cfg.port, 8080);
10394        assert_eq!(cfg.path, "/users/{id}");
10395        assert_eq!(cfg.method.as_deref(), Some("GET"));
10396
10397        // Lower-case httpMethod is uppercased (review I5).
10398        let cfg_lc =
10399            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
10400        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
10401        assert_eq!(cfg_lc.path, "/orders");
10402    }
10403
10404    // -----------------------------------------------------------------------
10405    // rc-1dk4: TypeConversionFailed → 400 Bad Request
10406    // -----------------------------------------------------------------------
10407
10408    #[test]
10409    fn type_conversion_failed_maps_to_400() {
10410        let reply = pipeline_error_to_reply(
10411            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
10412            "/api/users",
10413        );
10414        assert_eq!(reply.status, 400);
10415        // Content-Type must be application/json
10416        let ct = reply
10417            .headers
10418            .iter()
10419            .find(|(k, _)| k == "Content-Type")
10420            .map(|(_, v)| v.as_str());
10421        assert_eq!(ct, Some("application/json"));
10422        // Body must contain structured error JSON
10423        let body = match &reply.body {
10424            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10425            _ => panic!("expected bytes body"),
10426        };
10427        assert!(body.contains("\"error\""));
10428        assert!(body.contains("bad_request"));
10429        assert!(body.contains("invalid JSON at line 1"));
10430    }
10431
10432    #[test]
10433    fn other_error_still_maps_to_500() {
10434        let reply =
10435            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
10436        assert_eq!(reply.status, 500);
10437    }
10438
10439    #[test]
10440    fn unauthenticated_maps_to_401() {
10441        let reply = pipeline_error_to_reply(
10442            CamelError::Unauthenticated("no token".to_string()),
10443            "/api/users",
10444        );
10445        assert_eq!(reply.status, 401);
10446    }
10447
10448    #[test]
10449    fn unauthorized_maps_to_403() {
10450        let reply = pipeline_error_to_reply(
10451            CamelError::Unauthorized("forbidden".to_string()),
10452            "/api/users",
10453        );
10454        assert_eq!(reply.status, 403);
10455    }
10456
10457    #[test]
10458    fn validation_error_maps_to_400() {
10459        let reply = pipeline_error_to_reply(
10460            CamelError::ValidationError("body does not match schema".to_string()),
10461            "/api/users",
10462        );
10463        assert_eq!(reply.status, 400);
10464        let ct = reply
10465            .headers
10466            .iter()
10467            .find(|(k, _)| k == "Content-Type")
10468            .map(|(_, v)| v.as_str());
10469        assert_eq!(ct, Some("application/json"));
10470        let body = match &reply.body {
10471            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10472            _ => panic!("expected bytes body"),
10473        };
10474        assert!(body.contains("\"error\""));
10475        assert!(body.contains("validation_error"));
10476        assert!(body.contains("body does not match schema"));
10477    }
10478
10479    // -----------------------------------------------------------------------
10480    // rc-hlb1q: media negotiation errors → 415 / 406
10481    // -----------------------------------------------------------------------
10482
10483    #[test]
10484    fn finalizer_maps_unsupported_media_type() {
10485        let reply = pipeline_error_to_reply(
10486            CamelError::UnsupportedMediaType {
10487                consumed: "text/plain".to_string(),
10488                declared: "application/json".to_string(),
10489            },
10490            "/x",
10491        );
10492        assert_eq!(reply.status, 415);
10493        let ct = reply
10494            .headers
10495            .iter()
10496            .find(|(k, _)| k == "Content-Type")
10497            .map(|(_, v)| v.as_str());
10498        assert_eq!(ct, Some("application/json"));
10499        let body = match &reply.body {
10500            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10501            _ => panic!("expected bytes body"),
10502        };
10503        let parsed: serde_json::Value =
10504            serde_json::from_str(&body).expect("body must be valid JSON");
10505        assert_eq!(parsed["error"], "unsupported_media_type");
10506        let message = parsed["message"]
10507            .as_str()
10508            .expect("message must be a string");
10509        assert!(message.contains("text/plain"));
10510        assert!(message.contains("application/json"));
10511    }
10512
10513    #[test]
10514    fn finalizer_maps_not_acceptable() {
10515        let reply = pipeline_error_to_reply(
10516            CamelError::NotAcceptable {
10517                accept: "application/xml".to_string(),
10518                produced: "application/json".to_string(),
10519            },
10520            "/x",
10521        );
10522        assert_eq!(reply.status, 406);
10523        let ct = reply
10524            .headers
10525            .iter()
10526            .find(|(k, _)| k == "Content-Type")
10527            .map(|(_, v)| v.as_str());
10528        assert_eq!(ct, Some("application/json"));
10529        let body = match &reply.body {
10530            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10531            _ => panic!("expected bytes body"),
10532        };
10533        let parsed: serde_json::Value =
10534            serde_json::from_str(&body).expect("body must be valid JSON");
10535        assert_eq!(parsed["error"], "not_acceptable");
10536        let message = parsed["message"]
10537            .as_str()
10538            .expect("message must be a string");
10539        assert!(message.contains("application/xml"));
10540        assert!(message.contains("application/json"));
10541    }
10542
10543    #[test]
10544    fn https_consumer_without_tls_cert_errors() {
10545        let endpoint = HttpEndpoint {
10546            uri: "https://0.0.0.0:8443/api".to_string(),
10547            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
10548            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
10549            client: reqwest::Client::new(),
10550            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10551                PINNED_CLIENT_TTL,
10552                PINNED_CLIENT_MAX_ENTRIES,
10553            )),
10554            http_config: HttpConfig::default(),
10555        };
10556        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10557        let result = endpoint.create_consumer(rt);
10558        assert!(result.is_err(), "expected error for https without tls cert");
10559        if let Err(e) = result {
10560            let msg = e.to_string();
10561            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
10562        }
10563    }
10564
10565    #[test]
10566    fn http_consumer_with_tls_config_errors() {
10567        let endpoint = HttpEndpoint {
10568            uri: "http://0.0.0.0:8080/api".to_string(),
10569            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
10570            server_config: HttpServerConfig::from_uri(
10571                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
10572            )
10573            .unwrap(),
10574            client: reqwest::Client::new(),
10575            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10576                PINNED_CLIENT_TTL,
10577                PINNED_CLIENT_MAX_ENTRIES,
10578            )),
10579            http_config: HttpConfig::default(),
10580        };
10581        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10582        let result = endpoint.create_consumer(rt);
10583        assert!(result.is_err(), "expected error for http with tls config");
10584        if let Err(e) = result {
10585            let msg = e.to_string();
10586            assert!(msg.contains("https"), "error must mention https: {msg}");
10587        }
10588    }
10589
10590    #[test]
10591    fn https_consumer_with_partial_tls_cert_only_errors() {
10592        // tlsCert without tlsKey → tls_config is None at parse time
10593        // → create_consumer sees https:// + no TLS → must error
10594        let server_config =
10595            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
10596        assert!(
10597            server_config.tls_config.is_none(),
10598            "partial tlsCert must not create ServerTlsConfig"
10599        );
10600        let endpoint = HttpEndpoint {
10601            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
10602            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
10603                .unwrap(),
10604            server_config,
10605            client: reqwest::Client::new(),
10606            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10607                PINNED_CLIENT_TTL,
10608                PINNED_CLIENT_MAX_ENTRIES,
10609            )),
10610            http_config: HttpConfig::default(),
10611        };
10612        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10613        let result = endpoint.create_consumer(rt);
10614        assert!(
10615            result.is_err(),
10616            "must error: https:// requires both tlsCert and tlsKey"
10617        );
10618    }
10619
10620    #[test]
10621    fn load_tls_config_parses_valid_pem() {
10622        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
10623        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10624        use camel_component_api::test_support::tls;
10625        let (_, cert_pem, key_pem) = tls::gen_server_cert();
10626        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
10627        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
10628
10629        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
10630        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
10631    }
10632
10633    #[tokio::test(flavor = "multi_thread")]
10634    #[allow(clippy::await_holding_lock)]
10635    async fn consumer_tls_handshake_roundtrip() {
10636        use camel_component_api::test_support::tls;
10637        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10638
10639        // Install rustls crypto provider (aws-lc-rs)
10640        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10641
10642        // Serialize against global ServerRegistry singleton
10643        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
10644
10645        // Generate CA + server cert
10646        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
10647        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
10648        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
10649        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
10650
10651        // Get ephemeral port
10652        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10653        let port = probe.local_addr().unwrap().port();
10654        drop(probe);
10655
10656        ServerRegistry::reset();
10657
10658        // Create real HttpComponent + endpoint with TLS URI
10659        let component = HttpComponent::new();
10660        let endpoint_ctx = NoOpComponentContext;
10661        let uri = format!(
10662            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
10663            cert_path.to_string_lossy(),
10664            key_path.to_string_lossy(),
10665        );
10666        let endpoint = component
10667            .create_endpoint(&uri, &endpoint_ctx)
10668            .expect("create TLS endpoint");
10669        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
10670
10671        // Start consumer — this calls get_or_spawn with tls_config
10672        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10673        let token = tokio_util::sync::CancellationToken::new();
10674        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
10675        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10676
10677        // Give server time to start
10678        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
10679
10680        // Client with CA cert — REAL verification (no danger_accept_invalid)
10681        let ca_bytes = std::fs::read(&ca_path).unwrap();
10682        let client = reqwest::Client::builder()
10683            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
10684            .build()
10685            .unwrap();
10686
10687        let send_fut = client
10688            .post(format!("https://localhost:{port}/test"))
10689            .body("ping")
10690            .send();
10691
10692        // Handler: receive envelope, reply 200 with "pong" body
10693        let (http_result, _) = tokio::join!(send_fut, async {
10694            if let Some(mut envelope) = rx.recv().await {
10695                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
10696                if let Some(reply_tx) = envelope.reply_tx {
10697                    let _ = reply_tx.send(Ok(envelope.exchange));
10698                }
10699            }
10700        });
10701
10702        let resp = http_result.expect("TLS handshake + request must succeed");
10703
10704        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
10705        let body = resp.text().await.unwrap();
10706        assert_eq!(body, "pong");
10707
10708        token.cancel();
10709    }
10710
10711    #[tokio::test(flavor = "multi_thread")]
10712    #[allow(clippy::await_holding_lock)]
10713    async fn consumer_tls_rejects_client_without_ca() {
10714        use camel_component_api::test_support::tls;
10715        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10716
10717        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10718
10719        // Serialize against global ServerRegistry singleton
10720        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
10721
10722        let (_, cert_pem, key_pem) = tls::gen_server_cert();
10723        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
10724        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
10725
10726        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10727        let port = probe.local_addr().unwrap().port();
10728        drop(probe);
10729
10730        ServerRegistry::reset();
10731
10732        // Spawn TLS server via real HttpComponent path
10733        let component = HttpComponent::new();
10734        let endpoint_ctx = NoOpComponentContext;
10735        let uri = format!(
10736            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
10737            cert_path.to_string_lossy(),
10738            key_path.to_string_lossy(),
10739        );
10740        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
10741        let mut consumer = endpoint.create_consumer(rt()).unwrap();
10742        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10743        let token = tokio_util::sync::CancellationToken::new();
10744        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
10745        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10746
10747        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
10748
10749        // Client WITHOUT CA cert — must fail TLS verification
10750        let client = reqwest::Client::builder().build().unwrap();
10751
10752        let result = client
10753            .get(format!("https://localhost:{port}/test"))
10754            .send()
10755            .await;
10756
10757        assert!(
10758            result.is_err(),
10759            "must reject without CA — proves real verification"
10760        );
10761
10762        token.cancel();
10763    }
10764
10765    #[test]
10766    fn server_config_partial_tls_cert_without_key() {
10767        // Parse URI with only tlsCert (no tlsKey)
10768        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
10769        // Partial params → tls_config must be None
10770        assert!(cfg.tls_config.is_none());
10771    }
10772
10773    #[test]
10774    fn endpoint_uri_options_count_parity() {
10775        // Mirror struct must stay in sync with bespoke from_components parser.
10776        assert_eq!(
10777            HttpEndpointConfig::uri_options().len(),
10778            22,
10779            "HttpEndpointUriConfig #[uri_param] count drifted from parser"
10780        );
10781    }
10782
10783    fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
10784        pairs
10785            .iter()
10786            .map(|(k, v)| {
10787                (
10788                    (*k).to_string(),
10789                    serde_json::Value::String((*v).to_string()),
10790                )
10791            })
10792            .collect()
10793    }
10794
10795    #[test]
10796    fn response_emits_cache_control_via_pragma_warning() {
10797        let headers = make_headers(&[
10798            ("Cache-Control", "public, max-age=3600"),
10799            ("Via", "1.1 myproxy"),
10800            ("Pragma", "no-cache"),
10801            ("Warning", "199 misc"),
10802        ]);
10803        let selected = select_response_headers(&headers, None, None);
10804        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10805        for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
10806            assert!(
10807                names.contains(&expected),
10808                "{expected} should pass through to the response"
10809            );
10810        }
10811    }
10812
10813    #[test]
10814    fn response_excludes_request_only_and_server_owned() {
10815        let headers = make_headers(&[
10816            ("User-Agent", "x"),
10817            ("Accept", "*/*"),
10818            ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
10819        ]);
10820        let selected = select_response_headers(&headers, None, None);
10821        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10822        for excluded in ["User-Agent", "Accept", "Date"] {
10823            assert!(
10824                !names.contains(&excluded),
10825                "{excluded} should NOT appear in the response"
10826            );
10827        }
10828    }
10829
10830    #[test]
10831    fn response_re_derives_content_type() {
10832        let headers = make_headers(&[("Content-Type", "text/plain")]);
10833        let selected = select_response_headers(&headers, Some("application/json".into()), None);
10834        let ct_entries: Vec<&str> = selected
10835            .iter()
10836            .filter(|(k, _)| k == "Content-Type")
10837            .map(|(_, v)| v.as_str())
10838            .collect();
10839        assert_eq!(
10840            ct_entries,
10841            ["application/json"],
10842            "exactly one Content-Type entry, re-derived from user_content_type"
10843        );
10844    }
10845
10846    #[test]
10847    fn response_excludes_camel_headers() {
10848        let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
10849        let selected = select_response_headers(&headers, None, None);
10850        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10851        assert!(
10852            !names.contains(&"CamelHttpPath"),
10853            "Camel-namespace headers must be excluded"
10854        );
10855        assert!(
10856            names.contains(&"Cache-Control"),
10857            "Cache-Control must pass through"
10858        );
10859    }
10860
10861    #[test]
10862    fn response_stringifies_scalar_header_values() {
10863        let mut headers = make_headers(&[("X-Label", "keep")]);
10864        headers.insert("X-Retries".to_string(), serde_json::json!(3));
10865        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
10866        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
10867        let selected = select_response_headers(&headers, None, None);
10868        let get = |name: &str| -> Option<&str> {
10869            selected
10870                .iter()
10871                .find(|(k, _)| k == name)
10872                .map(|(_, v)| v.as_str())
10873        };
10874        assert_eq!(
10875            get("X-Retries"),
10876            Some("3"),
10877            "integer header must be stringified"
10878        );
10879        assert_eq!(
10880            get("X-Ratio"),
10881            Some("3.5"),
10882            "float header must be stringified"
10883        );
10884        assert_eq!(
10885            get("X-Enabled"),
10886            Some("true"),
10887            "bool header must be stringified"
10888        );
10889        assert_eq!(
10890            get("X-Label"),
10891            Some("keep"),
10892            "string header must pass through"
10893        );
10894    }
10895
10896    #[test]
10897    fn response_drops_null_and_structured_header_values() {
10898        let mut headers = make_headers(&[("X-Keep", "yes")]);
10899        headers.insert("X-Null".to_string(), serde_json::Value::Null);
10900        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
10901        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
10902        let selected = select_response_headers(&headers, None, None);
10903        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10904        for dropped in ["X-Null", "X-Obj", "X-Arr"] {
10905            assert!(
10906                !names.contains(&dropped),
10907                "{dropped} must not be emitted: no single-value form"
10908            );
10909        }
10910        assert!(names.contains(&"X-Keep"), "scalar headers must survive");
10911    }
10912
10913    #[test]
10914    fn response_stringifies_scalars_despite_excluded_names() {
10915        // Excluded names stay excluded regardless of value type: the policy
10916        // filter runs before stringification, so numeric values cannot smuggle
10917        // content-length or server-owned headers into the reply.
10918        let mut headers = HashMap::new();
10919        headers.insert("Content-Length".to_string(), serde_json::json!(999));
10920        headers.insert("Date".to_string(), serde_json::json!(12345));
10921        let selected = select_response_headers(&headers, None, None);
10922        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10923        assert!(
10924            !names.contains(&"Content-Length"),
10925            "content-length is re-derived by the server"
10926        );
10927        assert!(!names.contains(&"Date"), "date is server-owned");
10928    }
10929
10930    #[test]
10931    fn outbound_stringifies_scalar_header_values() {
10932        let mut headers = make_headers(&[("X-Label", "keep")]);
10933        headers.insert("X-Retries".to_string(), serde_json::json!(3));
10934        headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
10935        headers.insert("X-Enabled".to_string(), serde_json::json!(true));
10936        let outbound = select_outbound_headers(&headers, &[], &[]);
10937        // HeaderName construction lowercases; lookups compare case-blind.
10938        let get = |name: &str| -> Option<String> {
10939            outbound
10940                .accepted
10941                .iter()
10942                .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
10943                .map(|(_, v)| v.to_str().unwrap().to_string())
10944        };
10945        assert_eq!(
10946            get("X-Retries").as_deref(),
10947            Some("3"),
10948            "integer header must be stringified"
10949        );
10950        assert_eq!(
10951            get("X-Ratio").as_deref(),
10952            Some("3.5"),
10953            "float header must be stringified"
10954        );
10955        assert_eq!(
10956            get("X-Enabled").as_deref(),
10957            Some("true"),
10958            "bool header must be stringified"
10959        );
10960        assert_eq!(
10961            get("X-Label").as_deref(),
10962            Some("keep"),
10963            "string header must pass through"
10964        );
10965        assert!(outbound.drops.is_empty(), "scalar headers must not drop");
10966    }
10967
10968    #[test]
10969    fn outbound_drops_null_and_structured_header_values() {
10970        let mut headers = make_headers(&[("X-Keep", "yes")]);
10971        headers.insert("X-Null".to_string(), serde_json::Value::Null);
10972        headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
10973        headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
10974        let outbound = select_outbound_headers(&headers, &[], &[]);
10975        let has = |name: &str| {
10976            outbound
10977                .accepted
10978                .iter()
10979                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
10980        };
10981        assert!(has("X-Keep"), "scalar headers must survive");
10982        for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
10983            let dropped = outbound
10984                .drops
10985                .iter()
10986                .find(|d| d.name == name)
10987                .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
10988            assert_eq!(
10989                dropped.reason, "no scalar string form",
10990                "{name} drop reason must name the value kind absence"
10991            );
10992            assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
10993        }
10994    }
10995
10996    #[test]
10997    fn outbound_stringifies_scalars_despite_excluded_names() {
10998        // Excluded names stay excluded regardless of value type: the policy
10999        // filter runs before stringification, so numeric values cannot smuggle
11000        // hop-by-hop or client-derived headers onto the wire.
11001        let mut headers = HashMap::new();
11002        headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
11003        headers.insert("Host".to_string(), serde_json::json!(12345));
11004        headers.insert("X-Ok".to_string(), serde_json::json!(7));
11005        let outbound = select_outbound_headers(&headers, &[], &[]);
11006        let has = |name: &str| {
11007            outbound
11008                .accepted
11009                .iter()
11010                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11011        };
11012        assert!(
11013            !has("Transfer-Encoding"),
11014            "hop-by-hop header must stay excluded"
11015        );
11016        assert!(!has("Host"), "host is destination-derived");
11017        assert!(has("X-Ok"), "non-excluded scalar must be stringified");
11018        assert!(
11019            outbound
11020                .drops
11021                .iter()
11022                .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
11023            "policy drop must be recorded before coercion"
11024        );
11025    }
11026
11027    #[test]
11028    fn outbound_drops_invalid_names_values_and_skip_config() {
11029        let mut headers = make_headers(&[("X-Good", "fine")]);
11030        headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
11031        headers.insert(
11032            "X-Control-Value".to_string(),
11033            serde_json::json!("line1\nline2"),
11034        );
11035        headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
11036        headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
11037        let skip = vec!["x-secret".to_string()];
11038        let outbound = select_outbound_headers(&headers, &skip, &[]);
11039        let has = |name: &str| {
11040            outbound
11041                .accepted
11042                .iter()
11043                .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11044        };
11045        assert!(has("X-Good"), "valid header must survive");
11046        assert!(!has("X Bad Name"), "invalid header name must drop");
11047        assert!(!has("X-Control-Value"), "control-char value must drop");
11048        assert!(!has("X-Secret"), "skipped header must drop");
11049        assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
11050        let reason = |n: &str| {
11051            outbound
11052                .drops
11053                .iter()
11054                .find(|d| d.name == n)
11055                .map(|d| d.reason)
11056        };
11057        assert_eq!(reason("X Bad Name"), Some("invalid header name"));
11058        assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
11059        assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
11060        assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
11061    }
11062
11063    #[test]
11064    fn constructed_header_invalid_value_returns_drop_record() {
11065        let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
11066        let Err(record) = result else {
11067            panic!("invalid value must produce a drop record");
11068        };
11069        assert_eq!(record.reason, "invalid header value");
11070        assert_eq!(record.name, "user-agent");
11071        assert!(record.value_kind.is_none());
11072        let debug = format!("{record:?}");
11073        assert!(
11074            !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
11075            "drop record debug must not leak the value"
11076        );
11077    }
11078
11079    #[test]
11080    fn constructed_header_invalid_name_returns_drop_record() {
11081        let result = constructed_header("bad name", "ok");
11082        let Err(record) = result else {
11083            panic!("invalid name must produce a drop record");
11084        };
11085        assert_eq!(record.reason, "invalid header name");
11086        assert_eq!(record.name, "bad name");
11087        let debug = format!("{record:?}");
11088        assert!(
11089            !debug.contains("ok"),
11090            "drop record debug must not leak the value"
11091        );
11092    }
11093
11094    #[test]
11095    fn constructed_header_valid_pair_roundtrip() {
11096        let result = constructed_header("authorization", "Bearer abc123");
11097        let Ok((name, val)) = result else {
11098            panic!("valid pair must construct");
11099        };
11100        assert_eq!(name.as_str(), "authorization");
11101        let Ok(roundtrip) = val.to_str() else {
11102            panic!("valid value must roundtrip to str");
11103        };
11104        assert_eq!(roundtrip, "Bearer abc123");
11105    }
11106
11107    // -----------------------------------------------------------------------
11108    // Bridge proxy end-to-end integration tests (Task 4.1)
11109    // Fully local + deterministic: raw TCP / in-process consumer / reqwest
11110    // to 127.0.0.1. No public CDN, no httpbin, no network egress.
11111    // -----------------------------------------------------------------------
11112
11113    /// Destination server that captures the outbound request line and the
11114    /// `Host:` header the producer actually sent on the wire. Returns
11115    /// `(host_value, request_line)` so a bridge-proxy test can assert that
11116    /// the producer derived `Host` from the destination (not the exchange)
11117    /// and honoured bridging semantics for the path.
11118    async fn start_host_capturing_destination() -> (
11119        String,
11120        Arc<std::sync::Mutex<Option<(String, String)>>>,
11121        tokio::task::JoinHandle<()>,
11122    ) {
11123        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11124        let port = listener.local_addr().unwrap().port();
11125        let url = format!("http://127.0.0.1:{port}");
11126        let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
11127            Arc::new(std::sync::Mutex::new(None));
11128        let captured_clone = Arc::clone(&captured);
11129        let handle = tokio::spawn(async move {
11130            use tokio::io::{AsyncReadExt, AsyncWriteExt};
11131            if let Ok((mut stream, _)) = listener.accept().await {
11132                let mut buf = vec![0u8; 16384];
11133                let n = stream.read(&mut buf).await.unwrap_or(0);
11134                let request = String::from_utf8_lossy(&buf[..n]).to_string();
11135                if request.contains("\r\n\r\n") {
11136                    let request_line = request.lines().next().unwrap_or("").to_string();
11137                    let host_value = request
11138                        .lines()
11139                        .find(|l| l.to_lowercase().starts_with("host:"))
11140                        .and_then(|l| l.split_once(':'))
11141                        .map(|(_, v)| v.trim().to_string())
11142                        .unwrap_or_default();
11143                    *captured_clone.lock().unwrap() = Some((host_value, request_line));
11144                }
11145                let body = r#"{"echo":"ok"}"#;
11146                let resp = format!(
11147                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
11148                    body.len(),
11149                    body
11150                );
11151                let _ = stream.write_all(resp.as_bytes()).await;
11152            }
11153        });
11154        (url, captured, handle)
11155    }
11156
11157    /// A bridging producer must derive `Host` from the destination URL and
11158    /// ignore the exchange `CamelHttpPath`, matching Apache Camel bridging
11159    /// semantics. The wire-level proof is the raw `Host:` header and request
11160    /// line captured at the destination TCP socket.
11161    #[tokio::test]
11162    async fn bridge_proxy_outbound_host_matches_destination() {
11163        use tower::ServiceExt;
11164
11165        let (url, captured, _handle) = start_host_capturing_destination().await;
11166        // The Host header reqwest derives for http://127.0.0.1:{port} is the
11167        // authority, scheme-stripped: "127.0.0.1:{port}".
11168        let expected_host = url.strip_prefix("http://").unwrap();
11169
11170        let ctx = test_producer_ctx();
11171        let component = HttpComponent::new();
11172        let endpoint_ctx = NoOpComponentContext;
11173        let endpoint = component
11174            .create_endpoint(
11175                &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
11176                &endpoint_ctx,
11177            )
11178            .unwrap();
11179        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
11180
11181        // Exchange carries a stale Host and a CamelHttpPath that bridging
11182        // must drop.
11183        let mut exchange = Exchange::new(Message::default());
11184        exchange.input.set_header("Host", "localhost");
11185        exchange.input.set_header("CamelHttpPath", "/foo");
11186
11187        let result = producer.oneshot(exchange).await;
11188        assert!(result.is_ok(), "producer call failed: {:?}", result);
11189
11190        tokio::time::sleep(Duration::from_millis(100)).await;
11191        let (host_value, request_line) = captured
11192            .lock()
11193            .unwrap()
11194            .take()
11195            .expect("destination capture mutex empty — producer did not reach the destination");
11196
11197        assert_ne!(
11198            host_value, "localhost",
11199            "bridge producer must not forward the exchange Host: localhost"
11200        );
11201        assert_eq!(
11202            host_value, expected_host,
11203            "Host must be derived from the destination authority (no scheme)"
11204        );
11205        assert!(
11206            !request_line.contains("/foo"),
11207            "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
11208        );
11209    }
11210
11211    /// A response header set by the route (`Cache-Control`) must survive to
11212    /// the wire. The assertion is on the reqwest HTTP response — not an
11213    /// in-process HttpReply struct — so it proves the consumer's reply
11214    /// finaliser emitted the header over the socket.
11215    #[tokio::test]
11216    async fn bridge_proxy_route_set_response_header_survives() {
11217        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11218
11219        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11220        let port = listener.local_addr().unwrap().port();
11221        drop(listener);
11222
11223        let component = HttpComponent::new();
11224        let endpoint_ctx = NoOpComponentContext;
11225        let endpoint = component
11226            .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
11227            .unwrap();
11228        let mut consumer = endpoint.create_consumer(rt()).unwrap();
11229
11230        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11231        let token = tokio_util::sync::CancellationToken::new();
11232        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
11233
11234        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11235        tokio::time::sleep(Duration::from_millis(50)).await;
11236
11237        let client = reqwest::Client::new();
11238        let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
11239
11240        // Route sets Cache-Control on the outbound reply (exchange.input is
11241        // the message the reply finaliser reads — see select_response_headers
11242        // at the dispatch site).
11243        let (http_result, _) = tokio::join!(send_fut, async {
11244            if let Some(mut envelope) = rx.recv().await {
11245                envelope
11246                    .exchange
11247                    .input
11248                    .set_header("Cache-Control", "public, max-age=3600");
11249                if let Some(reply_tx) = envelope.reply_tx {
11250                    let _ = reply_tx.send(Ok(envelope.exchange));
11251                }
11252            }
11253        });
11254
11255        let resp = http_result.unwrap();
11256        assert_eq!(resp.status().as_u16(), 200);
11257
11258        let cache_control = resp.headers().get("cache-control");
11259        assert!(
11260            cache_control.is_some(),
11261            "Cache-Control header must survive to the wire response"
11262        );
11263        assert_eq!(
11264            cache_control.unwrap().to_str().unwrap(),
11265            "public, max-age=3600"
11266        );
11267
11268        token.cancel();
11269    }
11270
11271    // -----------------------------------------------------------------------
11272    // credential-sources task 2.3: credential values stay out of diagnostics
11273    // -----------------------------------------------------------------------
11274    //
11275    // camel-http has no request access log (design.md "Redaction sinks",
11276    // ADR-0051). The only diagnostic sink on the failed-auth path is
11277    // `pipeline_error_to_reply`, which renders the (generic) error message and
11278    // the *configured* route path — never the request URI, query string, or
11279    // extracted credential. These tests pin that redact-by-construction
11280    // contract: a sentinel credential presented in a declared source must not
11281    // appear in the reply body nor in any tracing record emitted while the
11282    // request is handled.
11283    //
11284    // Capture scope: `#[traced_test]` installs a per-crate env filter
11285    // (`camel_component_http=trace`), so records from OTHER targets
11286    // (`camel_auth`, `camel_processor`, etc.) are NOT captured here. The
11287    // redaction contract for those crates is guarded by their own tests.
11288    // Revisit this capture scope if camel-auth ever logs on the auth path.
11289    use camel_api::security_policy::CredentialSource;
11290    use camel_auth::credential_source::extract_token_from_exchange;
11291    use camel_auth::native_auth::NativeCredentialStore;
11292    use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
11293
11294    // Sentinel credential values — test fixtures only, not real secrets.
11295    const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; // allow-secret
11296    const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; // allow-secret
11297    const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; // allow-secret
11298
11299    /// Build the exchange the consumer would build for a request envelope:
11300    /// standard Camel HTTP headers plus title-cased forwarded request headers.
11301    fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
11302        let mut msg = Message::default();
11303        msg.set_header(
11304            "CamelHttpMethod",
11305            serde_json::Value::String(envelope.method.clone()),
11306        );
11307        msg.set_header(
11308            "CamelHttpPath",
11309            serde_json::Value::String(envelope.path.clone()),
11310        );
11311        msg.set_header(
11312            "CamelHttpQuery",
11313            serde_json::Value::String(envelope.query.clone()),
11314        );
11315        for (k, v) in &envelope.headers {
11316            if let Ok(val_str) = v.to_str() {
11317                msg.set_header(
11318                    title_case_header(k.as_str()),
11319                    serde_json::Value::String(val_str.to_string()),
11320                );
11321            }
11322        }
11323        Exchange::new(msg)
11324    }
11325
11326    /// Register a route whose responder authenticates each request against an
11327    /// empty native store, so every presented credential fails lookup with
11328    /// `Unauthenticated` (401). Restores the pre-AuthContext `RolePolicy`
11329    /// authentication step (extract per `sources` → authenticate → deny) so the
11330    /// credential-extraction redaction contract is exercised on a real
11331    /// authentication failure.
11332    async fn spawn_failing_auth_route(
11333        registry: &HttpRouteRegistry,
11334        path: &str,
11335        sources: Vec<CredentialSource>,
11336    ) {
11337        let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
11338            NativeCredentialStore::try_new(vec![]).unwrap(),
11339        ));
11340        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11341        registry.register_api_route(path.to_string(), tx).await;
11342        let path_owned = path.to_string();
11343        tokio::spawn(async move {
11344            while let Some(envelope) = rx.recv().await {
11345                let exchange = envelope_to_exchange(&envelope);
11346                let reply_tx = envelope.reply_tx;
11347                let result: Result<(), CamelError> = async {
11348                    let token = extract_token_from_exchange(&exchange, &sources)
11349                        .map(|extracted| extracted.token)
11350                        .ok_or_else(|| {
11351                            CamelError::Unauthenticated("no credential in any source".into())
11352                        })?;
11353                    authenticator.authenticate_bearer(&token).await?;
11354                    Ok(())
11355                }
11356                .await;
11357                let reply = match result {
11358                    Ok(()) => HttpReply {
11359                        status: 200,
11360                        headers: vec![],
11361                        body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
11362                    },
11363                    Err(e) => pipeline_error_to_reply(e, &path_owned),
11364                };
11365                let _ = reply_tx.send(reply);
11366            }
11367        });
11368    }
11369
11370    /// Whether any tracing record captured so far (process-wide) contains
11371    /// `needle`. `#[traced_test]` installs a global subscriber writing to a
11372    /// shared buffer, so logs from spawned request-handling tasks are included.
11373    fn captured_logs_contain(needle: &str) -> bool {
11374        let buf = tracing_test::internal::global_buf().lock().unwrap();
11375        String::from_utf8_lossy(&buf).contains(needle)
11376    }
11377
11378    #[tracing_test::traced_test]
11379    #[tokio::test]
11380    async fn error_context_redacts_query_sentinel() {
11381        let (port, registry) = spawn_test_server().await;
11382        spawn_failing_auth_route(
11383            &registry,
11384            "/secure-query",
11385            vec![CredentialSource::QueryParam {
11386                param: "token".to_string(),
11387            }],
11388        )
11389        .await;
11390
11391        let client = reqwest::Client::new();
11392        let resp = client
11393            // allow-secret: `token` is the declared query-source param name, not a credential
11394            .get(format!(
11395                "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
11396            ))
11397            .send()
11398            .await
11399            .unwrap();
11400
11401        assert_eq!(resp.status().as_u16(), 401);
11402        let body = resp.text().await.unwrap();
11403        assert_eq!(body, "Unauthorized");
11404        assert!(
11405            !body.contains(SENTINEL_QRY_42),
11406            "reply body must not contain the query credential"
11407        );
11408        assert!(
11409            !captured_logs_contain(SENTINEL_QRY_42),
11410            "no tracing record during request handling may render the query credential"
11411        );
11412        // Permanent positive control: the failed-auth warn! must be captured.
11413        // If the per-crate env filter ever stops matching, this fails loudly
11414        // instead of letting the sentinel assertions pass vacuously.
11415        assert!(
11416            captured_logs_contain("Authentication failed"),
11417            "positive control: the failed-auth warn! must be captured by the test subscriber"
11418        );
11419    }
11420
11421    #[tracing_test::traced_test]
11422    #[tokio::test]
11423    async fn error_context_redacts_cookie_sentinel() {
11424        let (port, registry) = spawn_test_server().await;
11425        spawn_failing_auth_route(
11426            &registry,
11427            "/secure-cookie",
11428            vec![CredentialSource::Cookie {
11429                name: "session".to_string(),
11430            }],
11431        )
11432        .await;
11433
11434        let client = reqwest::Client::new();
11435        let resp = client
11436            .get(format!("http://127.0.0.1:{port}/secure-cookie"))
11437            .header("Cookie", format!("session={SENTINEL_CKY_7}"))
11438            .send()
11439            .await
11440            .unwrap();
11441
11442        assert_eq!(resp.status().as_u16(), 401);
11443        let body = resp.text().await.unwrap();
11444        assert_eq!(body, "Unauthorized");
11445        assert!(
11446            !body.contains(SENTINEL_CKY_7),
11447            "reply body must not contain the cookie credential"
11448        );
11449        assert!(
11450            !captured_logs_contain(SENTINEL_CKY_7),
11451            "no tracing record during request handling may render the cookie credential"
11452        );
11453    }
11454
11455    #[tracing_test::traced_test]
11456    #[tokio::test]
11457    async fn error_reply_no_credential_value() {
11458        let (port, registry) = spawn_test_server().await;
11459        spawn_failing_auth_route(
11460            &registry,
11461            "/secure-bad",
11462            vec![CredentialSource::Cookie {
11463                name: "session".to_string(),
11464            }],
11465        )
11466        .await;
11467
11468        let client = reqwest::Client::new();
11469        let resp = client
11470            .get(format!("http://127.0.0.1:{port}/secure-bad"))
11471            .header("Cookie", format!("session={SENTINEL_BAD_1}"))
11472            .send()
11473            .await
11474            .unwrap();
11475
11476        assert_eq!(resp.status().as_u16(), 401);
11477        let body = resp.text().await.unwrap();
11478        assert_eq!(body, "Unauthorized");
11479        assert!(
11480            !body.contains(SENTINEL_BAD_1),
11481            "reply body must not contain the credential value"
11482        );
11483        assert!(
11484            !captured_logs_contain(SENTINEL_BAD_1),
11485            "error logs must not render the credential value"
11486        );
11487    }
11488
11489    // -----------------------------------------------------------------------
11490    // Pinned-client-cache producer-path behavioral tests
11491    // (openspec/changes/http-pinned-client-cache — scenarios: producers share
11492    // the endpoint cache, hostname requests build one client while the entry
11493    // stays retrievable, IP-literal requests bypass the cache)
11494    // -----------------------------------------------------------------------
11495
11496    /// Local responder that accepts any number of HTTP/1.1 connections on an
11497    /// ephemeral 127.0.0.1 port and answers each with a fixed 200 response.
11498    /// Unlike [`start_host_capturing_destination`], which serves exactly one
11499    /// connection, this loop keeps accepting so cache-reuse tests can drive
11500    /// several requests through one destination. Returns
11501    /// `(base_url, JoinHandle)`.
11502    async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
11503        use tokio::io::AsyncWriteExt;
11504
11505        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
11506            .await
11507            .expect("bind ephemeral 127.0.0.1 listener");
11508        let port = listener.local_addr().expect("local addr").port();
11509        let base_url = format!("http://localhost:{port}");
11510        let handle = tokio::spawn(async move {
11511            while let Ok((mut conn, _)) = listener.accept().await {
11512                let _ = conn
11513                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
11514                    .await;
11515                let _ = conn.shutdown().await;
11516            }
11517        });
11518        (base_url, handle)
11519    }
11520
11521    /// rc-0li3: local HTTPS responder — the TLS twin of
11522    /// [`spawn_multi_accept_200`]. Accepts any number of TLS connections on
11523    /// an ephemeral 127.0.0.1 port and answers each with a fixed 200. The
11524    /// certificate comes from `camel_component_api::test_support`
11525    /// (SANs: localhost, 127.0.0.1, ::1); clients run with
11526    /// `tls.insecure = true`.
11527    async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
11528        use tokio::io::AsyncWriteExt;
11529
11530        let (_ca_pem, cert_pem, key_pem) =
11531            camel_component_api::test_support::tls::gen_server_cert();
11532        let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
11533            .collect::<Result<_, _>>()
11534            .expect("parse server cert pem");
11535        let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
11536            .expect("parse server key pem")
11537            .expect("server key present");
11538        // Explicit provider: the process default is ambiguous when multiple
11539        // crates pull rustls feature sets; the graph enables aws-lc-rs.
11540        let provider =
11541            std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
11542        let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
11543            .with_safe_default_protocol_versions()
11544            .expect("safe default protocol versions")
11545            .with_no_client_auth()
11546            .with_single_cert(certs, key)
11547            .expect("build rustls server config");
11548        let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
11549
11550        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
11551            .await
11552            .expect("bind ephemeral 127.0.0.1 listener");
11553        let port = listener.local_addr().expect("local addr").port();
11554        let base_url = format!("https://localhost:{port}");
11555        let handle = tokio::spawn(async move {
11556            while let Ok((conn, _)) = listener.accept().await {
11557                let acceptor = acceptor.clone();
11558                tokio::spawn(async move {
11559                    if let Ok(mut tls) = acceptor.accept(conn).await {
11560                        let _ = tls
11561                            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
11562                            .await;
11563                        let _ = tls.shutdown().await;
11564                    }
11565                });
11566            }
11567        });
11568        (base_url, handle)
11569    }
11570
11571    /// Port of a [`spawn_multi_accept_200`] base URL, for tests that must
11572    /// target a different authority (the 127.0.0.1 literal) on the same
11573    /// listener.
11574    fn responder_port(base_url: &str) -> u16 {
11575        url::Url::parse(base_url)
11576            .expect("responder base URL parses")
11577            .port()
11578            .expect("responder base URL carries an explicit port")
11579    }
11580
11581    /// Build an endpoint literal whose outbound config points at
11582    /// `base_url` (with `allowInternal=true` so the SSRF resolver permits
11583    /// loopback) and whose pinned-client cache is the caller-owned Arc, so
11584    /// build counts stay observable across producers.
11585    fn endpoint_with_shared_cache(
11586        base_url: &str,
11587        pinned_cache: &Arc<PinnedClientCache>,
11588    ) -> HttpEndpoint {
11589        let uri = format!("{base_url}?allowInternal=true");
11590        HttpEndpoint {
11591            uri: uri.clone(),
11592            config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
11593            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
11594            client: reqwest::Client::new(),
11595            pinned_cache: Arc::clone(pinned_cache),
11596            http_config: HttpConfig::default(),
11597        }
11598    }
11599
11600    #[tokio::test]
11601    async fn producers_share_endpoint_cache() {
11602        use tower::ServiceExt;
11603
11604        let (base_url, _handle) = spawn_multi_accept_200().await;
11605        let pinned_cache = Arc::new(PinnedClientCache::new(
11606            PINNED_CLIENT_TTL,
11607            PINNED_CLIENT_MAX_ENTRIES,
11608        ));
11609
11610        let ctx = test_producer_ctx();
11611        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
11612        let producer_a = endpoint.create_producer(rt(), &ctx);
11613        let producer_b = endpoint.create_producer(rt(), &ctx);
11614
11615        // Each producer sends one exchange whose resolved URL is the
11616        // endpoint's localhost base URL (a domain name → pinned-client path).
11617        for producer in [producer_a, producer_b] {
11618            let producer = producer.expect("create producer");
11619            let exchange = Exchange::new(Message::default());
11620            let reply = producer.oneshot(exchange).await;
11621            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11622        }
11623
11624        assert_eq!(
11625            pinned_cache.build_count(),
11626            1,
11627            "both producers must hit the same shared cache entry; a second \
11628             build means sharing is broken"
11629        );
11630    }
11631
11632    #[tokio::test]
11633    async fn producer_repeated_hostname_requests_build_one_client() {
11634        use tower::ServiceExt;
11635
11636        let (base_url, _handle) = spawn_multi_accept_200().await;
11637        let pinned_cache = Arc::new(PinnedClientCache::new(
11638            PINNED_CLIENT_TTL,
11639            PINNED_CLIENT_MAX_ENTRIES,
11640        ));
11641        let ctx = test_producer_ctx();
11642        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
11643        let producer = endpoint
11644            .create_producer(rt(), &ctx)
11645            .expect("create producer");
11646
11647        // Two sequential hostname requests — the cached pinned client stays
11648        // retrievable between them, so no second build may happen.
11649        for i in 0..2 {
11650            let exchange = Exchange::new(Message::default());
11651            let reply = producer.clone().oneshot(exchange).await;
11652            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
11653        }
11654
11655        assert_eq!(
11656            pinned_cache.build_count(),
11657            1,
11658            "repeated hostname requests must reuse the one pinned client; \
11659             0 builds means the producer bypassed the cache, more than 1 \
11660             means the entry was dropped"
11661        );
11662    }
11663
11664    #[tokio::test]
11665    async fn ip_literal_request_never_enters_cache() {
11666        use tower::ServiceExt;
11667
11668        let (base_url, _handle) = spawn_multi_accept_200().await;
11669        let pinned_cache = Arc::new(PinnedClientCache::new(
11670            PINNED_CLIENT_TTL,
11671            PINNED_CLIENT_MAX_ENTRIES,
11672        ));
11673
11674        let ctx = test_producer_ctx();
11675        let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
11676        let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
11677        let producer = endpoint
11678            .create_producer(rt(), &ctx)
11679            .expect("create producer");
11680
11681        let exchange = Exchange::new(Message::default());
11682        let reply = producer.oneshot(exchange).await;
11683        assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11684
11685        assert_eq!(
11686            pinned_cache.build_count(),
11687            0,
11688            "an IP-literal URL must use the shared unpinned client and \
11689             never enter the pinned cache"
11690        );
11691    }
11692
11693    #[tokio::test]
11694    async fn test_component_endpoints_share_pinned_cache() {
11695        use tower::ServiceExt;
11696
11697        let component = HttpComponent::new();
11698        let (base_url, _handle) = spawn_multi_accept_200().await;
11699        let baseline = component.pinned_cache.build_count();
11700
11701        let ctx = test_producer_ctx();
11702        let endpoint_ctx = NoOpComponentContext;
11703        for uri in [
11704            format!("{base_url}/a?allowInternal=true&k=a"),
11705            format!("{base_url}/b?allowInternal=true&k=b"),
11706        ] {
11707            let endpoint = component
11708                .create_endpoint(&uri, &endpoint_ctx)
11709                .expect("create endpoint");
11710            let producer = endpoint
11711                .create_producer(rt(), &ctx)
11712                .expect("create producer");
11713            let exchange = Exchange::new(Message::default());
11714            let reply = producer.oneshot(exchange).await;
11715            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11716        }
11717
11718        assert_eq!(
11719            component.pinned_cache.build_count() - baseline,
11720            1,
11721            "endpoints created by one component must share its pinned cache; \
11722             0 builds means the endpoints bypassed it, more than 1 means \
11723             per-endpoint caches came back"
11724        );
11725    }
11726
11727    #[tokio::test]
11728    async fn test_dynamic_resolution_sequence_hits_shared_cache() {
11729        use tower::ServiceExt;
11730
11731        let component = HttpComponent::new();
11732        let (base_url, _handle) = spawn_multi_accept_200().await;
11733        let baseline = component.pinned_cache.build_count();
11734
11735        let ctx = test_producer_ctx();
11736        let endpoint_ctx = NoOpComponentContext;
11737        for i in 0..3 {
11738            let endpoint = component
11739                .create_endpoint(
11740                    &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
11741                    &endpoint_ctx,
11742                )
11743                .expect("create endpoint");
11744            let producer = endpoint
11745                .create_producer(rt(), &ctx)
11746                .expect("create producer");
11747            let exchange = Exchange::new(Message::default());
11748            let reply = producer.oneshot(exchange).await;
11749            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
11750        }
11751
11752        assert_eq!(
11753            component.pinned_cache.build_count() - baseline,
11754            1,
11755            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
11756             must reuse the component's one pinned cache entry; 0 builds \
11757             means the endpoints bypassed it, more than 1 means \
11758             per-endpoint caches came back"
11759        );
11760    }
11761
11762    /// rc-0li3: BEHAVIORAL https sharing pin — two https endpoints created
11763    /// through one `HttpsComponent` drive real TLS requests through the
11764    /// component's single pinned cache. A regression that reintroduces
11765    /// per-endpoint `PinnedClientCache::new` inside
11766    /// `HttpsComponent::create_endpoint` leaves the component cache at
11767    /// delta 0 and fails this test (the structural ptr_eq test cannot see
11768    /// that).
11769    #[tokio::test]
11770    async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
11771        use tower::ServiceExt;
11772
11773        let http_config = HttpConfig {
11774            tls: Some(crate::config::TlsConfig {
11775                enabled: true,
11776                insecure: true,
11777                ..Default::default()
11778            }),
11779            ..Default::default()
11780        };
11781        let component = HttpsComponent::with_config(http_config);
11782        let (base_url, _handle) = spawn_tls_multi_accept_200().await;
11783        let baseline = component.pinned_cache.build_count();
11784
11785        let ctx = test_producer_ctx();
11786        let endpoint_ctx = NoOpComponentContext;
11787        for uri in [
11788            format!("{base_url}/a?allowInternal=true&k=a"),
11789            format!("{base_url}/b?allowInternal=true&k=b"),
11790        ] {
11791            let endpoint = component
11792                .create_endpoint(&uri, &endpoint_ctx)
11793                .expect("create https endpoint");
11794            let producer = endpoint
11795                .create_producer(rt(), &ctx)
11796                .expect("create producer");
11797            let exchange = Exchange::new(Message::default());
11798            let reply = producer.oneshot(exchange).await;
11799            assert!(reply.is_ok(), "https request failed: {reply:?}");
11800        }
11801
11802        assert_eq!(
11803            component.pinned_cache.build_count() - baseline,
11804            1,
11805            "endpoints of one HttpsComponent must share its pinned cache over \
11806             real https requests; 0 builds means the endpoints bypassed it \
11807             (per-endpoint cache regression), more than 1 means \
11808             per-endpoint caches came back"
11809        );
11810    }
11811
11812    #[test]
11813    fn test_https_component_owns_distinct_cache() {
11814        let http = HttpComponent::new();
11815        let https = HttpsComponent::new();
11816
11817        assert!(
11818            !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
11819            "http and https components must each own their own pinned cache"
11820        );
11821
11822        let endpoint_ctx = NoOpComponentContext;
11823        let _ = http
11824            .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
11825            .expect("http endpoint");
11826        let _ = https
11827            .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
11828            .expect("https endpoint");
11829
11830        assert_eq!(
11831            http.pinned_cache.build_count(),
11832            0,
11833            "endpoint creation must not build a pinned client"
11834        );
11835        assert_eq!(
11836            https.pinned_cache.build_count(),
11837            0,
11838            "endpoint creation must not build a pinned client"
11839        );
11840    }
11841
11842    #[test]
11843    fn test_component_constructor_builds_one_unpinned_client() {
11844        let baseline = build_client_call_count();
11845
11846        let _http = HttpComponent::new();
11847        assert_eq!(
11848            build_client_call_count() - baseline,
11849            1,
11850            "HttpComponent::new() must build exactly one shared unpinned client"
11851        );
11852
11853        let _https = HttpsComponent::new();
11854        assert_eq!(
11855            build_client_call_count() - baseline,
11856            2,
11857            "HttpsComponent::new() must build exactly one more shared unpinned client"
11858        );
11859    }
11860
11861    #[test]
11862    fn test_component_endpoints_share_unpinned_client() {
11863        let component = HttpComponent::new();
11864        let baseline = build_client_call_count();
11865
11866        let endpoint_ctx = NoOpComponentContext;
11867        for uri in [
11868            "http://localhost:1/a?allowInternal=true",
11869            "http://localhost:1/b?allowInternal=true",
11870        ] {
11871            let _endpoint = component
11872                .create_endpoint(uri, &endpoint_ctx)
11873                .expect("create endpoint");
11874        }
11875
11876        assert_eq!(
11877            build_client_call_count() - baseline,
11878            0,
11879            "create_endpoint must clone the component's shared unpinned client, \
11880             never build a fresh one"
11881        );
11882    }
11883
11884    #[test]
11885    fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
11886        let component = HttpComponent::new();
11887        let baseline = build_client_call_count();
11888
11889        let ctx = test_producer_ctx();
11890        let endpoint_ctx = NoOpComponentContext;
11891        for i in 0..3 {
11892            let endpoint = component
11893                .create_endpoint(
11894                    &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
11895                    &endpoint_ctx,
11896                )
11897                .expect("create endpoint");
11898            let _producer = endpoint
11899                .create_producer(rt(), &ctx)
11900                .expect("create producer");
11901        }
11902
11903        assert_eq!(
11904            build_client_call_count() - baseline,
11905            0,
11906            "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
11907             must reuse the component's shared unpinned client and build \
11908             no additional clients"
11909        );
11910    }
11911}